javascript-solid-server 0.0.111 → 0.0.113
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/.claude/settings.local.json +2 -1
- package/README.md +13 -8
- package/bin/jss.js +1 -5
- package/docs/configuration.md +0 -37
- package/docs/invites.md +43 -0
- package/docs/mashlib.md +58 -0
- package/docs/nostr.md +56 -0
- package/docs/notifications.md +50 -0
- package/docs/quotas.md +36 -0
- package/package.json +1 -1
- package/src/auth/middleware.js +4 -7
- package/src/config.js +1 -6
- package/src/handlers/resource.js +7 -13
- package/src/server.js +11 -74
- package/src/webrtc/index.js +189 -6
- package/test/webrtc.test.js +154 -0
|
@@ -327,7 +327,8 @@
|
|
|
327
327
|
"Bash(gh label:*)",
|
|
328
328
|
"Bash(mongosh --eval \"db.runCommand\\({ ping: 1 }\\)\" 2>&1 | head -5)",
|
|
329
329
|
"Bash(which jss && jss --version 2>&1)",
|
|
330
|
-
"Bash(jss start --help 2>&1 | grep -i mongo)"
|
|
330
|
+
"Bash(jss start --help 2>&1 | grep -i mongo)",
|
|
331
|
+
"Bash(grep -A5 '\"\"files\"\"' package.json)"
|
|
331
332
|
]
|
|
332
333
|
}
|
|
333
334
|
}
|
package/README.md
CHANGED
|
@@ -91,23 +91,28 @@ Full options: [docs/configuration.md](docs/configuration.md)
|
|
|
91
91
|
|-------|------|
|
|
92
92
|
| Configuration & Options | [docs/configuration.md](docs/configuration.md) |
|
|
93
93
|
| Authentication | [docs/authentication.md](docs/authentication.md) |
|
|
94
|
+
| Mashlib / SolidOS UI | [docs/mashlib.md](docs/mashlib.md) |
|
|
95
|
+
| WebSocket Notifications | [docs/notifications.md](docs/notifications.md) |
|
|
94
96
|
| Git Support | [docs/git-support.md](docs/git-support.md) |
|
|
97
|
+
| Nostr Relay | [docs/nostr.md](docs/nostr.md) |
|
|
95
98
|
| ActivityPub & Mastodon API | [docs/activitypub.md](docs/activitypub.md) |
|
|
96
99
|
| remoteStorage | [docs/remotestorage.md](docs/remotestorage.md) |
|
|
97
|
-
| Security & Subdomain Mode | [docs/security.md](docs/security.md) |
|
|
98
|
-
| HTTP 402 Payments | [docs/payments.md](docs/payments.md) |
|
|
99
100
|
| WebRTC & Tunnel | [docs/webrtc.md](docs/webrtc.md) |
|
|
100
101
|
| MongoDB `/db/` Route | [docs/mongodb.md](docs/mongodb.md) |
|
|
102
|
+
| HTTP 402 Payments | [docs/payments.md](docs/payments.md) |
|
|
103
|
+
| Storage Quotas | [docs/quotas.md](docs/quotas.md) |
|
|
104
|
+
| Invite-Only Registration | [docs/invites.md](docs/invites.md) |
|
|
105
|
+
| Security & Subdomain Mode | [docs/security.md](docs/security.md) |
|
|
101
106
|
| Architecture & Structure | [docs/architecture.md](docs/architecture.md) |
|
|
102
107
|
|
|
103
108
|
## Comparison
|
|
104
109
|
|
|
105
|
-
| Server |
|
|
106
|
-
|
|
107
|
-
| [JSS](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer) | ~
|
|
108
|
-
| [
|
|
109
|
-
| [
|
|
110
|
-
| [
|
|
110
|
+
| Server | Package | Packages | node_modules |
|
|
111
|
+
|--------|---------|----------|-------------|
|
|
112
|
+
| [JSS](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer) | ~1 MB | ~191 | ~77 MB |
|
|
113
|
+
| [CSS](https://github.com/CommunitySolidServer/CommunitySolidServer) | ~6 MB | ~311 | ~152 MB |
|
|
114
|
+
| [Pivot](https://github.com/solid-contrib/pivot) | ~6 MB | ~311+ | ~152 MB |
|
|
115
|
+
| [NSS](https://github.com/nodeSolidServer/node-solid-server) | ~7 MB | ~670 | ~539 MB |
|
|
111
116
|
|
|
112
117
|
## Performance
|
|
113
118
|
|
package/bin/jss.js
CHANGED
|
@@ -53,12 +53,10 @@ program
|
|
|
53
53
|
.option('--subdomains', 'Enable subdomain-based pods (XSS protection)')
|
|
54
54
|
.option('--no-subdomains', 'Disable subdomain-based pods')
|
|
55
55
|
.option('--base-domain <domain>', 'Base domain for subdomain pods (e.g., "example.com")')
|
|
56
|
-
.option('--mashlib', 'Enable Mashlib data browser (
|
|
57
|
-
.option('--mashlib-cdn', 'Enable Mashlib data browser (CDN mode, no local files needed)')
|
|
56
|
+
.option('--mashlib-cdn', 'Enable Mashlib data browser (CDN mode)')
|
|
58
57
|
.option('--mashlib-module <url>', 'Enable ES module data browser from a URL')
|
|
59
58
|
.option('--no-mashlib', 'Disable Mashlib data browser')
|
|
60
59
|
.option('--mashlib-version <version>', 'Mashlib version for CDN mode (default: 2.0.0)')
|
|
61
|
-
.option('--solidos-ui', 'Enable modern Nextcloud-style UI (requires --mashlib)')
|
|
62
60
|
.option('--git', 'Enable Git HTTP backend (clone/push support)')
|
|
63
61
|
.option('--no-git', 'Disable Git HTTP backend')
|
|
64
62
|
.option('--nostr', 'Enable Nostr relay')
|
|
@@ -143,7 +141,6 @@ program
|
|
|
143
141
|
mashlibCdn: config.mashlibCdn,
|
|
144
142
|
mashlibVersion: config.mashlibVersion,
|
|
145
143
|
mashlibModule: config.mashlibModule,
|
|
146
|
-
solidosUi: config.solidosUi,
|
|
147
144
|
git: config.git,
|
|
148
145
|
nostr: config.nostr,
|
|
149
146
|
nostrPath: config.nostrPath,
|
|
@@ -193,7 +190,6 @@ program
|
|
|
193
190
|
console.log(` Mashlib: local (data browser enabled)`);
|
|
194
191
|
}
|
|
195
192
|
if (config.mashlibModule) console.log(` Mashlib module: ${config.mashlibModule}`);
|
|
196
|
-
if (config.solidosUi) console.log(' SolidOS UI: enabled (modern interface)');
|
|
197
193
|
if (config.git) console.log(' Git: enabled (clone/push support)');
|
|
198
194
|
if (config.nostr) console.log(` Nostr: enabled (${config.nostrPath})`);
|
|
199
195
|
if (config.webrtc) console.log(` WebRTC: enabled (${config.webrtcPath || '/.webrtc'})`);
|
package/docs/configuration.md
CHANGED
|
@@ -355,43 +355,6 @@ jss quota reconcile alice
|
|
|
355
355
|
|
|
356
356
|
Supported formats: `50MB`, `1GB`, `500KB`, `1TB`
|
|
357
357
|
|
|
358
|
-
## Storage Quotas
|
|
359
|
-
|
|
360
|
-
Limit storage per pod to prevent abuse and manage resources:
|
|
361
|
-
|
|
362
|
-
```bash
|
|
363
|
-
jss start --default-quota 50MB
|
|
364
|
-
```
|
|
365
|
-
|
|
366
|
-
### Managing Quotas
|
|
367
|
-
|
|
368
|
-
```bash
|
|
369
|
-
# Set quota for a user (overrides default)
|
|
370
|
-
jss quota set alice 100MB
|
|
371
|
-
|
|
372
|
-
# Show quota info
|
|
373
|
-
jss quota show alice
|
|
374
|
-
# alice:
|
|
375
|
-
# Used: 12.5 MB
|
|
376
|
-
# Limit: 100 MB
|
|
377
|
-
# Free: 87.5 MB
|
|
378
|
-
# Usage: 12%
|
|
379
|
-
|
|
380
|
-
# Recalculate from actual disk usage
|
|
381
|
-
jss quota reconcile alice
|
|
382
|
-
```
|
|
383
|
-
|
|
384
|
-
### How It Works
|
|
385
|
-
|
|
386
|
-
- Quotas are tracked incrementally on PUT, POST, and DELETE operations
|
|
387
|
-
- When quota is exceeded, the server returns HTTP 507 Insufficient Storage
|
|
388
|
-
- Each pod stores its quota in `/{pod}/.quota.json`
|
|
389
|
-
- Use `reconcile` to fix quota drift from manual file changes
|
|
390
|
-
|
|
391
|
-
### Size Formats
|
|
392
|
-
|
|
393
|
-
Supported formats: `50MB`, `1GB`, `500KB`, `1TB`
|
|
394
|
-
|
|
395
358
|
### Mashlib Data Browser
|
|
396
359
|
|
|
397
360
|
Enable the [SolidOS Mashlib](https://github.com/SolidOS/mashlib) data browser for RDF resources. Two modes are available:
|
package/docs/invites.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Invite-Only Registration
|
|
2
|
+
|
|
3
|
+
Control who can create accounts by requiring invite codes.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
jss start --idp --invite-only
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Managing Invite Codes
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# Create a single-use invite
|
|
13
|
+
jss invite create
|
|
14
|
+
# Created invite code: ABCD1234
|
|
15
|
+
|
|
16
|
+
# Create multi-use invite with note
|
|
17
|
+
jss invite create -u 5 -n "For team members"
|
|
18
|
+
|
|
19
|
+
# List all active invites
|
|
20
|
+
jss invite list
|
|
21
|
+
# CODE USES CREATED NOTE
|
|
22
|
+
# -------------------------------------------------------
|
|
23
|
+
# ABCD1234 0/1 2026-01-03
|
|
24
|
+
# EFGH5678 2/5 2026-01-03 For team members
|
|
25
|
+
|
|
26
|
+
# Revoke an invite
|
|
27
|
+
jss invite revoke ABCD1234
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## How It Works
|
|
31
|
+
|
|
32
|
+
| Mode | Registration | Pod Creation |
|
|
33
|
+
|------|--------------|--------------|
|
|
34
|
+
| Open (default) | Anyone can register | Anyone can create pods |
|
|
35
|
+
| Invite-only | Requires valid invite code | Via registration only |
|
|
36
|
+
|
|
37
|
+
When `--invite-only` is enabled:
|
|
38
|
+
- The registration page shows an "Invite Code" field
|
|
39
|
+
- Invalid or expired codes are rejected with an error
|
|
40
|
+
- Each use decrements the invite's remaining uses
|
|
41
|
+
- Depleted invites are automatically removed
|
|
42
|
+
|
|
43
|
+
Invite codes are stored in `.server/invites.json` in your data directory.
|
package/docs/mashlib.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Mashlib Data Browser
|
|
2
|
+
|
|
3
|
+
Enable the [SolidOS Mashlib](https://github.com/SolidOS/mashlib) data browser for RDF resources.
|
|
4
|
+
|
|
5
|
+
## Modes
|
|
6
|
+
|
|
7
|
+
**CDN Mode** (recommended for getting started):
|
|
8
|
+
```bash
|
|
9
|
+
jss start --mashlib-cdn --conneg
|
|
10
|
+
```
|
|
11
|
+
Loads mashlib from unpkg.com CDN. Zero footprint — no local files needed.
|
|
12
|
+
|
|
13
|
+
**Local Mode** (for production/offline):
|
|
14
|
+
```bash
|
|
15
|
+
jss start --mashlib --conneg
|
|
16
|
+
```
|
|
17
|
+
Serves mashlib from `src/mashlib-local/dist/`. Requires building mashlib locally:
|
|
18
|
+
```bash
|
|
19
|
+
cd src/mashlib-local
|
|
20
|
+
npm install && npm run build
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**ES Module Mode** (for custom or next-gen mashlib builds):
|
|
24
|
+
```bash
|
|
25
|
+
jss start --mashlib-module https://example.com/mashlib.js
|
|
26
|
+
```
|
|
27
|
+
Loads an ES module-based data browser from any URL. Uses `<script type="module">` and `<div id="mashlib">` (self-initializing). CSS is auto-derived by replacing `.js` with `.css`. Content negotiation is auto-enabled.
|
|
28
|
+
|
|
29
|
+
## How It Works
|
|
30
|
+
|
|
31
|
+
1. Browser requests `/alice/public/data.ttl` with `Accept: text/html`
|
|
32
|
+
2. Server returns Mashlib HTML wrapper
|
|
33
|
+
3. Mashlib fetches the actual data via content negotiation
|
|
34
|
+
4. Mashlib renders an interactive, editable view
|
|
35
|
+
|
|
36
|
+
**Note:** Mashlib works best with `--conneg` enabled for Turtle support.
|
|
37
|
+
|
|
38
|
+
## Modern UI (SolidOS UI)
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
jss start --mashlib --solidos-ui --conneg
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Serves a modern Nextcloud-style UI shell while reusing mashlib's data layer:
|
|
45
|
+
- Modern file browser with breadcrumb navigation
|
|
46
|
+
- Profile, Contacts, Sharing, and Settings views
|
|
47
|
+
- Path-based URLs (browser URL reflects current resource)
|
|
48
|
+
- Responsive design for mobile devices
|
|
49
|
+
|
|
50
|
+
Requires solidos-ui dist files in `src/mashlib-local/dist/solidos-ui/`. See [solidos-ui](https://github.com/solidos/solidos/tree/main/workspaces/solidos-ui) for details.
|
|
51
|
+
|
|
52
|
+
## Profile Pages
|
|
53
|
+
|
|
54
|
+
Pod profiles (`/alice/`) use HTML with embedded JSON-LD data islands and are rendered using:
|
|
55
|
+
- [mashlib-jss](https://github.com/JavaScriptSolidServer/mashlib-jss) — A fork of mashlib with `getPod()` fix for path-based pods
|
|
56
|
+
- [solidos-lite](https://github.com/SolidOS/solidos-lite) — Parses JSON-LD data islands into the RDF store
|
|
57
|
+
|
|
58
|
+
This allows profiles to work without server-side content negotiation while still providing full SolidOS editing capabilities.
|
package/docs/nostr.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Nostr Relay
|
|
2
|
+
|
|
3
|
+
Integrated NIP-01/NIP-11/NIP-16 Nostr relay running on the same port as the Solid server.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
jss start --nostr
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Endpoint
|
|
10
|
+
|
|
11
|
+
`wss://your.pod/relay` (configurable via `--nostr-path`)
|
|
12
|
+
|
|
13
|
+
## Supported NIPs
|
|
14
|
+
|
|
15
|
+
- **NIP-01** — Basic protocol flow (EVENT, REQ, CLOSE)
|
|
16
|
+
- **NIP-11** — Relay information document (`GET /relay` with `Accept: application/nostr+json`)
|
|
17
|
+
- **NIP-16** — Event treatment (regular, replaceable, ephemeral)
|
|
18
|
+
|
|
19
|
+
## Options
|
|
20
|
+
|
|
21
|
+
| Option | Description | Default |
|
|
22
|
+
|--------|-------------|---------|
|
|
23
|
+
| `--nostr` | Enable Nostr relay | false |
|
|
24
|
+
| `--nostr-path <path>` | WebSocket path | /relay |
|
|
25
|
+
| `--nostr-max-events <n>` | Max events in memory | 1000 |
|
|
26
|
+
|
|
27
|
+
## How It Works
|
|
28
|
+
|
|
29
|
+
- Events are stored in memory (up to `--nostr-max-events`)
|
|
30
|
+
- Replaceable events (kinds 0, 3, 10000-19999) replace previous events by the same pubkey
|
|
31
|
+
- Ephemeral events (kinds 20000-29999) are broadcast but not stored
|
|
32
|
+
- Parameterized replaceable events (kinds 30000-39999) use the `d` tag for deduplication
|
|
33
|
+
- Rate limiting: 60 events per socket per minute
|
|
34
|
+
|
|
35
|
+
## Client Usage
|
|
36
|
+
|
|
37
|
+
```javascript
|
|
38
|
+
import { Relay } from 'nostr-tools';
|
|
39
|
+
|
|
40
|
+
const relay = await Relay.connect('wss://your.pod/relay');
|
|
41
|
+
|
|
42
|
+
// Subscribe
|
|
43
|
+
const sub = relay.subscribe([{ kinds: [1], limit: 10 }], {
|
|
44
|
+
onevent(event) { console.log(event); }
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Publish
|
|
48
|
+
await relay.publish(signedEvent);
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Nostr Authentication (NIP-98)
|
|
52
|
+
|
|
53
|
+
JSS also supports NIP-98 HTTP Auth for Solid operations. See [docs/authentication.md](authentication.md) for details on:
|
|
54
|
+
- NIP-98 Schnorr signature authentication
|
|
55
|
+
- `did:nostr` → WebID resolution
|
|
56
|
+
- Linking Nostr identity to your WebID profile
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# WebSocket Notifications
|
|
2
|
+
|
|
3
|
+
Real-time notifications for resource changes using the solid-0.1 protocol (SolidOS compatible).
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
jss start --notifications
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Discovery
|
|
10
|
+
|
|
11
|
+
Clients discover the WebSocket URL via the `Updates-Via` header:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
curl -I http://localhost:3000/alice/public/
|
|
15
|
+
# Updates-Via: ws://localhost:3000/.notifications
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Protocol
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
Server: protocol solid-0.1
|
|
22
|
+
Client: sub http://localhost:3000/alice/public/data.json
|
|
23
|
+
Server: ack http://localhost:3000/alice/public/data.json
|
|
24
|
+
Server: pub http://localhost:3000/alice/public/data.json (on change)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## How It Works
|
|
28
|
+
|
|
29
|
+
1. Client connects to the WebSocket URL from `Updates-Via`
|
|
30
|
+
2. Server sends `protocol solid-0.1` greeting
|
|
31
|
+
3. Client subscribes: `sub <resource-url>`
|
|
32
|
+
4. Server acknowledges: `ack <resource-url>`
|
|
33
|
+
5. On any change (PUT, PATCH, DELETE), server broadcasts: `pub <resource-url>`
|
|
34
|
+
6. Container subscriptions also fire when child resources change
|
|
35
|
+
|
|
36
|
+
## ACL Enforcement
|
|
37
|
+
|
|
38
|
+
- Anonymous clients can subscribe to public resources
|
|
39
|
+
- Private resource subscriptions require authentication
|
|
40
|
+
- Cross-origin subscriptions are rejected
|
|
41
|
+
|
|
42
|
+
## Live Reload
|
|
43
|
+
|
|
44
|
+
For development, `--live-reload` injects a script that auto-refreshes the browser when files change on disk:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
jss start --live-reload --notifications
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
File system changes (editing files directly) also trigger WebSocket notifications.
|
package/docs/quotas.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Storage Quotas
|
|
2
|
+
|
|
3
|
+
Limit storage per pod to prevent abuse and manage resources.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
jss start --default-quota 50MB
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Managing Quotas
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# Set quota for a user (overrides default)
|
|
13
|
+
jss quota set alice 100MB
|
|
14
|
+
|
|
15
|
+
# Show quota info
|
|
16
|
+
jss quota show alice
|
|
17
|
+
# alice:
|
|
18
|
+
# Used: 12.5 MB
|
|
19
|
+
# Limit: 100 MB
|
|
20
|
+
# Free: 87.5 MB
|
|
21
|
+
# Usage: 12%
|
|
22
|
+
|
|
23
|
+
# Recalculate from actual disk usage
|
|
24
|
+
jss quota reconcile alice
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## How It Works
|
|
28
|
+
|
|
29
|
+
- Quotas are tracked incrementally on PUT, POST, and DELETE operations
|
|
30
|
+
- When quota is exceeded, the server returns HTTP 507 Insufficient Storage
|
|
31
|
+
- Each pod stores its quota in `/{pod}/.quota.json`
|
|
32
|
+
- Use `reconcile` to fix quota drift from manual file changes
|
|
33
|
+
|
|
34
|
+
## Size Formats
|
|
35
|
+
|
|
36
|
+
Supported formats: `50MB`, `1GB`, `500KB`, `1TB`
|
package/package.json
CHANGED
package/src/auth/middleware.js
CHANGED
|
@@ -9,7 +9,7 @@ import { checkAccess, getRequiredMode } from '../wac/checker.js';
|
|
|
9
9
|
import { AccessMode } from '../wac/parser.js';
|
|
10
10
|
import * as storage from '../storage/filesystem.js';
|
|
11
11
|
import { getEffectiveUrlPath } from '../utils/url.js';
|
|
12
|
-
import { generateDatabrowserHtml, generateModuleDatabrowserHtml
|
|
12
|
+
import { generateDatabrowserHtml, generateModuleDatabrowserHtml } from '../mashlib/index.js';
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Build a resource URL for WAC checking, normalizing path-based pod access
|
|
@@ -148,12 +148,9 @@ export function handleUnauthorized(request, reply, isAuthenticated, wacAllow, au
|
|
|
148
148
|
// If mashlib is enabled, serve mashlib instead of static error page
|
|
149
149
|
// Mashlib has built-in login functionality via panes.runDataBrowser()
|
|
150
150
|
if (request.mashlibEnabled) {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
?
|
|
154
|
-
: request.mashlibModule
|
|
155
|
-
? generateModuleDatabrowserHtml(request.mashlibModule)
|
|
156
|
-
: generateDatabrowserHtml(request.url, request.mashlibCdn ? request.mashlibVersion : null);
|
|
151
|
+
const html = request.mashlibModule
|
|
152
|
+
? generateModuleDatabrowserHtml(request.mashlibModule)
|
|
153
|
+
: generateDatabrowserHtml(request.url, request.mashlibCdn ? request.mashlibVersion : null);
|
|
157
154
|
return reply.code(statusCode).type('text/html').send(html);
|
|
158
155
|
}
|
|
159
156
|
return reply.code(statusCode).type('text/html').send(getErrorPage(statusCode, isAuthenticated, request));
|
package/src/config.js
CHANGED
|
@@ -43,9 +43,6 @@ export const defaults = {
|
|
|
43
43
|
mashlibVersion: '2.0.0',
|
|
44
44
|
mashlibModule: false,
|
|
45
45
|
|
|
46
|
-
// SolidOS UI (modern Nextcloud-style interface)
|
|
47
|
-
solidosUi: false,
|
|
48
|
-
|
|
49
46
|
// Git HTTP backend
|
|
50
47
|
git: false,
|
|
51
48
|
|
|
@@ -137,7 +134,6 @@ const envMap = {
|
|
|
137
134
|
JSS_MASHLIB_CDN: 'mashlibCdn',
|
|
138
135
|
JSS_MASHLIB_VERSION: 'mashlibVersion',
|
|
139
136
|
JSS_MASHLIB_MODULE: 'mashlibModule',
|
|
140
|
-
JSS_SOLIDOS_UI: 'solidosUi',
|
|
141
137
|
JSS_GIT: 'git',
|
|
142
138
|
JSS_NOSTR: 'nostr',
|
|
143
139
|
JSS_NOSTR_PATH: 'nostrPath',
|
|
@@ -331,8 +327,7 @@ export function printConfig(config) {
|
|
|
331
327
|
console.log(` Notifications: ${config.notifications}`);
|
|
332
328
|
console.log(` IdP: ${config.idp ? (config.idpIssuer || 'enabled') : 'disabled'}`);
|
|
333
329
|
console.log(` Subdomains: ${config.subdomains ? (config.baseDomain || 'enabled') : 'disabled'}`);
|
|
334
|
-
console.log(` Mashlib: ${config.mashlibModule ? `module (${config.mashlibModule})` : config.mashlibCdn ? `CDN v${config.mashlibVersion}` :
|
|
335
|
-
console.log(` SolidOS UI: ${config.solidosUi ? 'enabled' : 'disabled'}`);
|
|
330
|
+
console.log(` Mashlib: ${config.mashlibModule ? `module (${config.mashlibModule})` : config.mashlibCdn ? `CDN v${config.mashlibVersion}` : 'disabled'}`);
|
|
336
331
|
if (config.pay) {
|
|
337
332
|
console.log(` Pay: ${config.payCost} sat/req`);
|
|
338
333
|
if (config.payToken) console.log(` Token: ${config.payToken} @ ${config.payRate} sat/token`);
|
package/src/handlers/resource.js
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
} from '../rdf/conneg.js';
|
|
16
16
|
import { emitChange } from '../notifications/events.js';
|
|
17
17
|
import { checkIfMatch, checkIfNoneMatchForGet, checkIfNoneMatchForWrite } from '../utils/conditional.js';
|
|
18
|
-
import { generateDatabrowserHtml, generateModuleDatabrowserHtml,
|
|
18
|
+
import { generateDatabrowserHtml, generateModuleDatabrowserHtml, shouldServeMashlib } from '../mashlib/index.js';
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Live reload script - injected into HTML when --live-reload is enabled
|
|
@@ -230,12 +230,9 @@ export async function handleGet(request, reply) {
|
|
|
230
230
|
|
|
231
231
|
// Check if we should serve Mashlib data browser for containers
|
|
232
232
|
if (shouldServeMashlib(request, request.mashlibEnabled, 'application/ld+json')) {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
?
|
|
236
|
-
: request.mashlibModule
|
|
237
|
-
? generateModuleDatabrowserHtml(request.mashlibModule)
|
|
238
|
-
: generateDatabrowserHtml(resourceUrl, request.mashlibCdn ? request.mashlibVersion : null);
|
|
233
|
+
const html = request.mashlibModule
|
|
234
|
+
? generateModuleDatabrowserHtml(request.mashlibModule)
|
|
235
|
+
: generateDatabrowserHtml(resourceUrl, request.mashlibCdn ? request.mashlibVersion : null);
|
|
239
236
|
const headers = getAllHeaders({
|
|
240
237
|
isContainer: true,
|
|
241
238
|
etag: stats.etag,
|
|
@@ -309,12 +306,9 @@ export async function handleGet(request, reply) {
|
|
|
309
306
|
// Check if we should serve Mashlib data browser
|
|
310
307
|
// Only for RDF resources when Accept: text/html is requested
|
|
311
308
|
if (shouldServeMashlib(request, request.mashlibEnabled, storedContentType)) {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
?
|
|
315
|
-
: request.mashlibModule
|
|
316
|
-
? generateModuleDatabrowserHtml(request.mashlibModule)
|
|
317
|
-
: generateDatabrowserHtml(resourceUrl, request.mashlibCdn ? request.mashlibVersion : null);
|
|
309
|
+
const html = request.mashlibModule
|
|
310
|
+
? generateModuleDatabrowserHtml(request.mashlibModule)
|
|
311
|
+
: generateDatabrowserHtml(resourceUrl, request.mashlibCdn ? request.mashlibVersion : null);
|
|
318
312
|
const headers = getAllHeaders({
|
|
319
313
|
isContainer: false,
|
|
320
314
|
etag: stats.etag,
|
package/src/server.js
CHANGED
|
@@ -62,14 +62,11 @@ export function createServer(options = {}) {
|
|
|
62
62
|
const subdomainsEnabled = options.subdomains ?? false;
|
|
63
63
|
const baseDomain = options.baseDomain || null;
|
|
64
64
|
// Mashlib data browser is OFF by default
|
|
65
|
-
// mashlibCdn:
|
|
66
|
-
// mashlibModule: URL to ES module entry point (alternative to classic mashlib)
|
|
65
|
+
// mashlibCdn: load from CDN; mashlibModule: URL to ES module entry point
|
|
67
66
|
const mashlibModule = options.mashlibModule ?? false;
|
|
68
|
-
const mashlibEnabled = options.mashlib || !!mashlibModule;
|
|
69
67
|
const mashlibCdn = options.mashlibCdn ?? false;
|
|
68
|
+
const mashlibEnabled = mashlibCdn || !!mashlibModule;
|
|
70
69
|
const mashlibVersion = options.mashlibVersion ?? '2.0.0';
|
|
71
|
-
// SolidOS UI (modern Nextcloud-style interface) - requires mashlib
|
|
72
|
-
const solidosUiEnabled = options.solidosUi ?? false;
|
|
73
70
|
// Git HTTP backend is OFF by default - enables clone/push via git protocol
|
|
74
71
|
const gitEnabled = options.git ?? false;
|
|
75
72
|
// Nostr relay is OFF by default
|
|
@@ -179,7 +176,6 @@ export function createServer(options = {}) {
|
|
|
179
176
|
fastify.decorateRequest('mashlibCdn', null);
|
|
180
177
|
fastify.decorateRequest('mashlibVersion', null);
|
|
181
178
|
fastify.decorateRequest('mashlibModule', null);
|
|
182
|
-
fastify.decorateRequest('solidosUiEnabled', null);
|
|
183
179
|
fastify.decorateRequest('defaultQuota', null);
|
|
184
180
|
fastify.decorateRequest('config', null);
|
|
185
181
|
fastify.decorateRequest('liveReloadEnabled', null);
|
|
@@ -193,7 +189,6 @@ export function createServer(options = {}) {
|
|
|
193
189
|
request.mashlibCdn = mashlibCdn;
|
|
194
190
|
request.mashlibVersion = mashlibVersion;
|
|
195
191
|
request.mashlibModule = mashlibModule;
|
|
196
|
-
request.solidosUiEnabled = solidosUiEnabled;
|
|
197
192
|
request.defaultQuota = defaultQuota;
|
|
198
193
|
request.config = { public: options.public, readOnly: options.readOnly };
|
|
199
194
|
request.liveReloadEnabled = liveReloadEnabled;
|
|
@@ -410,7 +405,7 @@ export function createServer(options = {}) {
|
|
|
410
405
|
// Authorization hook - check WAC permissions
|
|
411
406
|
// Skip for pod creation endpoint (needs special handling)
|
|
412
407
|
fastify.addHook('preHandler', async (request, reply) => {
|
|
413
|
-
// Skip auth for pod creation, OPTIONS, IdP routes, mashlib,
|
|
408
|
+
// Skip auth for pod creation, OPTIONS, IdP routes, mashlib, well-known, notifications, nostr, git, and AP
|
|
414
409
|
const mashlibPaths = ['/mashlib.min.js', '/mash.css', '/841.mashlib.min.js'];
|
|
415
410
|
const apPaths = ['/inbox', '/profile/card/inbox', '/profile/card/outbox', '/profile/card/followers', '/profile/card/following',
|
|
416
411
|
'/api/v1/apps', '/api/v1/instance', '/api/v1/accounts/verify_credentials',
|
|
@@ -424,7 +419,6 @@ export function createServer(options = {}) {
|
|
|
424
419
|
request.method === 'OPTIONS' ||
|
|
425
420
|
request.url.startsWith('/idp/') ||
|
|
426
421
|
request.url.startsWith('/.well-known/') ||
|
|
427
|
-
request.url.startsWith('/solidos-ui/') ||
|
|
428
422
|
(nostrEnabled && request.url.startsWith(nostrPath)) ||
|
|
429
423
|
(gitEnabled && isGitRequest(request.url)) ||
|
|
430
424
|
(activitypubEnabled && apPaths.some(p => request.url === p || request.url.startsWith(p + '?'))) ||
|
|
@@ -464,72 +458,15 @@ export function createServer(options = {}) {
|
|
|
464
458
|
}
|
|
465
459
|
}, handleCreatePod);
|
|
466
460
|
|
|
467
|
-
// Mashlib
|
|
468
|
-
if (mashlibEnabled) {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
// Mashlib uses code splitting, so it loads chunks like 789.mashlib.min.js
|
|
472
|
-
const cdnBase = `https://unpkg.com/mashlib@${mashlibVersion}/dist`;
|
|
473
|
-
const chunkPattern = /^\/\d+\.mashlib\.min\.js(\.map)?$/;
|
|
474
|
-
|
|
475
|
-
fastify.addHook('onRequest', async (request, reply) => {
|
|
476
|
-
if (chunkPattern.test(request.url)) {
|
|
477
|
-
const filename = request.url.split('/').pop();
|
|
478
|
-
return reply.redirect(302, `${cdnBase}/${filename}`);
|
|
479
|
-
}
|
|
480
|
-
});
|
|
481
|
-
} else {
|
|
482
|
-
// Local mode: serve from local files
|
|
483
|
-
const mashlibDir = join(__dirname, 'mashlib-local', 'dist');
|
|
484
|
-
const mashlibFiles = {
|
|
485
|
-
'/mashlib.min.js': { file: 'mashlib.min.js', type: 'application/javascript' },
|
|
486
|
-
'/mashlib.min.js.map': { file: 'mashlib.min.js.map', type: 'application/json' },
|
|
487
|
-
'/mash.css': { file: 'mash.css', type: 'text/css' },
|
|
488
|
-
'/mash.css.map': { file: 'mash.css.map', type: 'application/json' },
|
|
489
|
-
'/841.mashlib.min.js': { file: '841.mashlib.min.js', type: 'application/javascript' },
|
|
490
|
-
'/841.mashlib.min.js.map': { file: '841.mashlib.min.js.map', type: 'application/json' }
|
|
491
|
-
};
|
|
492
|
-
|
|
493
|
-
for (const [path, config] of Object.entries(mashlibFiles)) {
|
|
494
|
-
fastify.get(path, async (request, reply) => {
|
|
495
|
-
try {
|
|
496
|
-
const content = await readFile(join(mashlibDir, config.file));
|
|
497
|
-
return reply.type(config.type).send(content);
|
|
498
|
-
} catch {
|
|
499
|
-
return reply.code(404).send({ error: 'Not Found' });
|
|
500
|
-
}
|
|
501
|
-
});
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
}
|
|
461
|
+
// Mashlib CDN mode: redirect chunk requests to CDN
|
|
462
|
+
if (mashlibEnabled && mashlibCdn) {
|
|
463
|
+
const cdnBase = `https://unpkg.com/mashlib@${mashlibVersion}/dist`;
|
|
464
|
+
const chunkPattern = /^\/\d+\.mashlib\.min\.js(\.map)?$/;
|
|
505
465
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
// Serve all files under /solidos-ui/* path
|
|
512
|
-
fastify.get('/solidos-ui/*', async (request, reply) => {
|
|
513
|
-
try {
|
|
514
|
-
// Get the path after /solidos-ui/
|
|
515
|
-
const filePath = request.url.replace('/solidos-ui/', '').split('?')[0];
|
|
516
|
-
const fullPath = join(solidosUiDir, filePath);
|
|
517
|
-
|
|
518
|
-
// Determine content type based on extension
|
|
519
|
-
const ext = filePath.split('.').pop()?.toLowerCase();
|
|
520
|
-
const contentTypes = {
|
|
521
|
-
'js': 'application/javascript',
|
|
522
|
-
'css': 'text/css',
|
|
523
|
-
'map': 'application/json',
|
|
524
|
-
'html': 'text/html'
|
|
525
|
-
};
|
|
526
|
-
const contentType = contentTypes[ext] || 'application/octet-stream';
|
|
527
|
-
|
|
528
|
-
const content = await readFile(fullPath);
|
|
529
|
-
return reply.type(contentType).send(content);
|
|
530
|
-
} catch (err) {
|
|
531
|
-
request.log.error(err, 'Failed to serve solidos-ui file');
|
|
532
|
-
return reply.code(404).send({ error: 'Not Found' });
|
|
466
|
+
fastify.addHook('onRequest', async (request, reply) => {
|
|
467
|
+
if (chunkPattern.test(request.url)) {
|
|
468
|
+
const filename = request.url.split('/').pop();
|
|
469
|
+
return reply.redirect(302, `${cdnBase}/${filename}`);
|
|
533
470
|
}
|
|
534
471
|
});
|
|
535
472
|
}
|
package/src/webrtc/index.js
CHANGED
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
* WebRTC Signaling Server Plugin
|
|
3
3
|
*
|
|
4
4
|
* Lightweight signaling server for WebRTC peer-to-peer connections.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Supports two discovery modes:
|
|
6
|
+
*
|
|
7
|
+
* 1. Identity-based — connect to a specific peer by WebID
|
|
8
|
+
* 2. Content-addressed — find peers sharing the same resource hash
|
|
9
|
+
*
|
|
10
|
+
* Relays SDP offers/answers and ICE candidates between peers.
|
|
11
|
+
* The actual media/data flows directly between peers — JSS just introduces them.
|
|
7
12
|
*
|
|
8
13
|
* Usage: jss start --webrtc
|
|
9
14
|
* Endpoint: wss://your.pod/.webrtc
|
|
10
15
|
*
|
|
11
|
-
*
|
|
16
|
+
* Identity-based protocol (JSON over WebSocket):
|
|
12
17
|
* → { type: "offer", to: "<webid>", sdp: "..." }
|
|
13
18
|
* → { type: "answer", to: "<webid>", sdp: "..." }
|
|
14
19
|
* → { type: "candidate", to: "<webid>", candidate: {...} }
|
|
@@ -21,6 +26,14 @@
|
|
|
21
26
|
* ← { type: "peers", you: "<webid>", peers: ["<webid>", ...] }
|
|
22
27
|
* ← { type: "peer-joined", webId: "<webid>" }
|
|
23
28
|
* ← { type: "peer-left", webId: "<webid>" }
|
|
29
|
+
*
|
|
30
|
+
* Content-addressed protocol (JSON over WebSocket):
|
|
31
|
+
* → { type: "announce", resource: "<hash>", offers: [{ sdp: "...", offer_id: "..." }, ...] }
|
|
32
|
+
* → { type: "answer", resource: "<hash>", to: "<peer_id>", offer_id: "...", sdp: "..." }
|
|
33
|
+
* → { type: "leave", resource: "<hash>" }
|
|
34
|
+
* ← { type: "offer", resource: "<hash>", from: "<peer_id>", offer_id: "...", sdp: "..." }
|
|
35
|
+
* ← { type: "answer", resource: "<hash>", from: "<peer_id>", offer_id: "...", sdp: "..." }
|
|
36
|
+
* ← { type: "resource-peers", resource: "<hash>", count: <n> }
|
|
24
37
|
*/
|
|
25
38
|
|
|
26
39
|
import websocket from '@fastify/websocket';
|
|
@@ -28,6 +41,9 @@ import { getWebIdFromRequestAsync } from '../auth/token.js';
|
|
|
28
41
|
|
|
29
42
|
const ALLOWED_TYPES = new Set(['offer', 'answer', 'candidate', 'hangup']);
|
|
30
43
|
const MAX_MESSAGE_SIZE = 64 * 1024; // 64KB
|
|
44
|
+
const MAX_OFFERS_PER_ANNOUNCE = 10;
|
|
45
|
+
const MAX_RESOURCES_PER_PEER = 50;
|
|
46
|
+
const RESOURCE_HASH_RE = /^[a-fA-F0-9]{8,128}$/;
|
|
31
47
|
|
|
32
48
|
/**
|
|
33
49
|
* Register WebRTC signaling routes on Fastify instance
|
|
@@ -39,9 +55,20 @@ const MAX_MESSAGE_SIZE = 64 * 1024; // 64KB
|
|
|
39
55
|
export async function webrtcPlugin(fastify, options = {}) {
|
|
40
56
|
const path = options.path || '/.webrtc';
|
|
41
57
|
|
|
42
|
-
// Instance-scoped peer state
|
|
58
|
+
// Instance-scoped peer state (identity-based)
|
|
43
59
|
const peers = new Map();
|
|
44
60
|
|
|
61
|
+
// Instance-scoped resource state (content-addressed)
|
|
62
|
+
// Map<resourceHash, Map<peerId, socket>>
|
|
63
|
+
const resources = new Map();
|
|
64
|
+
|
|
65
|
+
// Track which resources each peer has joined
|
|
66
|
+
// Map<peerId, Set<resourceHash>>
|
|
67
|
+
const peerResources = new Map();
|
|
68
|
+
|
|
69
|
+
// Peer ID counter for content-addressed mode
|
|
70
|
+
let nextPeerId = 1;
|
|
71
|
+
|
|
45
72
|
// Only register @fastify/websocket if not already registered
|
|
46
73
|
if (!fastify.websocketServer) {
|
|
47
74
|
await fastify.register(websocket);
|
|
@@ -53,6 +80,8 @@ export async function webrtcPlugin(fastify, options = {}) {
|
|
|
53
80
|
socket.close();
|
|
54
81
|
}
|
|
55
82
|
peers.clear();
|
|
83
|
+
resources.clear();
|
|
84
|
+
peerResources.clear();
|
|
56
85
|
});
|
|
57
86
|
|
|
58
87
|
function broadcast(senderWebId, msg) {
|
|
@@ -64,6 +93,130 @@ export async function webrtcPlugin(fastify, options = {}) {
|
|
|
64
93
|
}
|
|
65
94
|
}
|
|
66
95
|
|
|
96
|
+
// --- Content-addressed helpers ---
|
|
97
|
+
|
|
98
|
+
function getResourcePeers(resourceHash) {
|
|
99
|
+
let group = resources.get(resourceHash);
|
|
100
|
+
if (!group) {
|
|
101
|
+
group = new Map();
|
|
102
|
+
resources.set(resourceHash, group);
|
|
103
|
+
}
|
|
104
|
+
return group;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function addPeerToResource(peerId, socket, resourceHash) {
|
|
108
|
+
const group = getResourcePeers(resourceHash);
|
|
109
|
+
group.set(peerId, socket);
|
|
110
|
+
|
|
111
|
+
let tracked = peerResources.get(peerId);
|
|
112
|
+
if (!tracked) {
|
|
113
|
+
tracked = new Set();
|
|
114
|
+
peerResources.set(peerId, tracked);
|
|
115
|
+
}
|
|
116
|
+
tracked.add(resourceHash);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function removePeerFromResource(peerId, resourceHash) {
|
|
120
|
+
const group = resources.get(resourceHash);
|
|
121
|
+
if (group) {
|
|
122
|
+
group.delete(peerId);
|
|
123
|
+
if (group.size === 0) resources.delete(resourceHash);
|
|
124
|
+
}
|
|
125
|
+
const tracked = peerResources.get(peerId);
|
|
126
|
+
if (tracked) {
|
|
127
|
+
tracked.delete(resourceHash);
|
|
128
|
+
if (tracked.size === 0) peerResources.delete(peerId);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function removePeerFromAllResources(peerId) {
|
|
133
|
+
const tracked = peerResources.get(peerId);
|
|
134
|
+
if (!tracked) return;
|
|
135
|
+
for (const hash of tracked) {
|
|
136
|
+
const group = resources.get(hash);
|
|
137
|
+
if (group) {
|
|
138
|
+
group.delete(peerId);
|
|
139
|
+
if (group.size === 0) resources.delete(hash);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
peerResources.delete(peerId);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function handleAnnounce(socket, peerId, msg) {
|
|
146
|
+
const hash = msg.resource;
|
|
147
|
+
if (!hash || typeof hash !== 'string' || !RESOURCE_HASH_RE.test(hash)) {
|
|
148
|
+
socket.send(JSON.stringify({ type: 'error', message: 'Invalid resource hash' }));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Limit resources per peer
|
|
153
|
+
const tracked = peerResources.get(peerId);
|
|
154
|
+
if (tracked && tracked.size >= MAX_RESOURCES_PER_PEER && !tracked.has(hash)) {
|
|
155
|
+
socket.send(JSON.stringify({ type: 'error', message: 'Too many resources' }));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const group = getResourcePeers(hash);
|
|
160
|
+
addPeerToResource(peerId, socket, hash);
|
|
161
|
+
|
|
162
|
+
// Relay offers to existing peers in the group
|
|
163
|
+
const offers = Array.isArray(msg.offers) ? msg.offers.slice(0, MAX_OFFERS_PER_ANNOUNCE) : [];
|
|
164
|
+
const existingPeers = [...group.entries()].filter(([id]) => id !== peerId);
|
|
165
|
+
|
|
166
|
+
for (let i = 0; i < offers.length && i < existingPeers.length; i++) {
|
|
167
|
+
const offer = offers[i];
|
|
168
|
+
const [targetId, targetSocket] = existingPeers[i];
|
|
169
|
+
if (targetSocket.readyState !== 1) continue;
|
|
170
|
+
if (typeof offer.sdp !== 'string') continue;
|
|
171
|
+
|
|
172
|
+
const relay = Object.create(null);
|
|
173
|
+
relay.type = 'offer';
|
|
174
|
+
relay.resource = hash;
|
|
175
|
+
relay.from = peerId;
|
|
176
|
+
relay.offer_id = typeof offer.offer_id === 'string' ? offer.offer_id : String(i);
|
|
177
|
+
relay.sdp = offer.sdp;
|
|
178
|
+
try { targetSocket.send(JSON.stringify(relay)); } catch { /* peer gone */ }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Tell the announcer how many peers are in the group
|
|
182
|
+
socket.send(JSON.stringify({
|
|
183
|
+
type: 'resource-peers',
|
|
184
|
+
resource: hash,
|
|
185
|
+
count: group.size - 1
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function handleResourceAnswer(socket, peerId, msg) {
|
|
190
|
+
const hash = msg.resource;
|
|
191
|
+
if (!hash || typeof hash !== 'string') return;
|
|
192
|
+
|
|
193
|
+
const group = resources.get(hash);
|
|
194
|
+
if (!group) return;
|
|
195
|
+
|
|
196
|
+
const targetId = msg.to;
|
|
197
|
+
const targetSocket = group.get(targetId);
|
|
198
|
+
if (!targetSocket || targetSocket.readyState !== 1) {
|
|
199
|
+
socket.send(JSON.stringify({ type: 'error', message: 'Peer not in resource group' }));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const relay = Object.create(null);
|
|
204
|
+
relay.type = 'answer';
|
|
205
|
+
relay.resource = hash;
|
|
206
|
+
relay.from = peerId;
|
|
207
|
+
if (typeof msg.offer_id === 'string') relay.offer_id = msg.offer_id;
|
|
208
|
+
if (typeof msg.sdp === 'string') relay.sdp = msg.sdp;
|
|
209
|
+
try { targetSocket.send(JSON.stringify(relay)); } catch { /* peer gone */ }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function handleLeave(socket, peerId, msg) {
|
|
213
|
+
const hash = msg.resource;
|
|
214
|
+
if (!hash || typeof hash !== 'string') return;
|
|
215
|
+
removePeerFromResource(peerId, hash);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// --- WebSocket handler ---
|
|
219
|
+
|
|
67
220
|
fastify.get(path, { websocket: true }, async (connection, request) => {
|
|
68
221
|
const socket = connection.socket;
|
|
69
222
|
|
|
@@ -75,10 +228,16 @@ export async function webrtcPlugin(fastify, options = {}) {
|
|
|
75
228
|
return;
|
|
76
229
|
}
|
|
77
230
|
|
|
231
|
+
// Assign a stable peer ID for content-addressed mode
|
|
232
|
+
const peerId = String(nextPeerId++);
|
|
233
|
+
socket._peerId = peerId;
|
|
234
|
+
|
|
78
235
|
// Register this peer (close old connection if reconnecting)
|
|
79
236
|
const existing = peers.get(webId);
|
|
80
237
|
const isReconnect = !!existing;
|
|
81
238
|
if (existing) {
|
|
239
|
+
// Clean up old connection's resource memberships
|
|
240
|
+
if (existing._peerId) removePeerFromAllResources(existing._peerId);
|
|
82
241
|
peers.delete(webId);
|
|
83
242
|
existing.close();
|
|
84
243
|
}
|
|
@@ -89,6 +248,7 @@ export async function webrtcPlugin(fastify, options = {}) {
|
|
|
89
248
|
socket.send(JSON.stringify({
|
|
90
249
|
type: 'peers',
|
|
91
250
|
you: webId,
|
|
251
|
+
peerId: peerId,
|
|
92
252
|
peers: [...peers.keys()].filter(id => id !== webId)
|
|
93
253
|
}));
|
|
94
254
|
|
|
@@ -113,8 +273,28 @@ export async function webrtcPlugin(fastify, options = {}) {
|
|
|
113
273
|
return;
|
|
114
274
|
}
|
|
115
275
|
|
|
116
|
-
if (!msg.
|
|
117
|
-
socket.send(JSON.stringify({ type: 'error', message: 'Missing "
|
|
276
|
+
if (!msg.type) {
|
|
277
|
+
socket.send(JSON.stringify({ type: 'error', message: 'Missing "type" field' }));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Content-addressed messages
|
|
282
|
+
if (msg.type === 'announce') {
|
|
283
|
+
handleAnnounce(socket, peerId, msg);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (msg.type === 'answer' && msg.resource) {
|
|
287
|
+
handleResourceAnswer(socket, peerId, msg);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (msg.type === 'leave') {
|
|
291
|
+
handleLeave(socket, peerId, msg);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Identity-based messages require "to" field
|
|
296
|
+
if (!msg.to) {
|
|
297
|
+
socket.send(JSON.stringify({ type: 'error', message: 'Missing "to" field' }));
|
|
118
298
|
return;
|
|
119
299
|
}
|
|
120
300
|
|
|
@@ -144,6 +324,9 @@ export async function webrtcPlugin(fastify, options = {}) {
|
|
|
144
324
|
});
|
|
145
325
|
|
|
146
326
|
socket.on('close', () => {
|
|
327
|
+
// Clean up content-addressed resource memberships
|
|
328
|
+
removePeerFromAllResources(peerId);
|
|
329
|
+
|
|
147
330
|
// Only remove if this socket is still the registered one (not replaced by reconnect)
|
|
148
331
|
if (peers.get(webId) === socket) {
|
|
149
332
|
peers.delete(webId);
|
package/test/webrtc.test.js
CHANGED
|
@@ -209,4 +209,158 @@ describe('WebRTC Signaling', () => {
|
|
|
209
209
|
await new Promise(r => setTimeout(r, 50));
|
|
210
210
|
});
|
|
211
211
|
});
|
|
212
|
+
|
|
213
|
+
describe('Content-Addressed Peer Discovery', () => {
|
|
214
|
+
const RESOURCE_HASH = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2';
|
|
215
|
+
|
|
216
|
+
it('should return peer count on announce', async () => {
|
|
217
|
+
const { ws: alice } = await connectAndWait('alice');
|
|
218
|
+
|
|
219
|
+
alice.send(JSON.stringify({
|
|
220
|
+
type: 'announce',
|
|
221
|
+
resource: RESOURCE_HASH,
|
|
222
|
+
offers: []
|
|
223
|
+
}));
|
|
224
|
+
|
|
225
|
+
const msg = await waitForMessage(alice, 'resource-peers');
|
|
226
|
+
assert.strictEqual(msg.resource, RESOURCE_HASH);
|
|
227
|
+
assert.strictEqual(msg.count, 0);
|
|
228
|
+
|
|
229
|
+
alice.close();
|
|
230
|
+
await new Promise(r => setTimeout(r, 50));
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('should relay offers between peers sharing a resource', async () => {
|
|
234
|
+
const { ws: alice, peerId: alicePeerId } = await connectAndWait('alice');
|
|
235
|
+
|
|
236
|
+
// Alice announces with no offers (first in group)
|
|
237
|
+
alice.send(JSON.stringify({
|
|
238
|
+
type: 'announce',
|
|
239
|
+
resource: RESOURCE_HASH,
|
|
240
|
+
offers: []
|
|
241
|
+
}));
|
|
242
|
+
await waitForMessage(alice, 'resource-peers');
|
|
243
|
+
|
|
244
|
+
// Bob announces with an offer — should be relayed to Alice
|
|
245
|
+
const offerPromise = waitForMessage(alice, 'offer');
|
|
246
|
+
const { ws: bob, peerId: bobPeerId } = await connectAndWait('bob');
|
|
247
|
+
|
|
248
|
+
bob.send(JSON.stringify({
|
|
249
|
+
type: 'announce',
|
|
250
|
+
resource: RESOURCE_HASH,
|
|
251
|
+
offers: [{ sdp: 'v=0\r\nbob-offer', offer_id: 'offer1' }]
|
|
252
|
+
}));
|
|
253
|
+
|
|
254
|
+
const offer = await offerPromise;
|
|
255
|
+
assert.strictEqual(offer.type, 'offer');
|
|
256
|
+
assert.strictEqual(offer.resource, RESOURCE_HASH);
|
|
257
|
+
assert.strictEqual(offer.from, bobPeerId);
|
|
258
|
+
assert.strictEqual(offer.offer_id, 'offer1');
|
|
259
|
+
assert.ok(offer.sdp.includes('bob-offer'));
|
|
260
|
+
|
|
261
|
+
// Alice answers Bob
|
|
262
|
+
const answerPromise = waitForMessage(bob, 'answer');
|
|
263
|
+
alice.send(JSON.stringify({
|
|
264
|
+
type: 'answer',
|
|
265
|
+
resource: RESOURCE_HASH,
|
|
266
|
+
to: bobPeerId,
|
|
267
|
+
offer_id: 'offer1',
|
|
268
|
+
sdp: 'v=0\r\nalice-answer'
|
|
269
|
+
}));
|
|
270
|
+
|
|
271
|
+
const answer = await answerPromise;
|
|
272
|
+
assert.strictEqual(answer.type, 'answer');
|
|
273
|
+
assert.strictEqual(answer.resource, RESOURCE_HASH);
|
|
274
|
+
assert.strictEqual(answer.from, alicePeerId);
|
|
275
|
+
assert.ok(answer.sdp.includes('alice-answer'));
|
|
276
|
+
|
|
277
|
+
alice.close();
|
|
278
|
+
bob.close();
|
|
279
|
+
await new Promise(r => setTimeout(r, 50));
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('should clean up resources on disconnect', async () => {
|
|
283
|
+
const { ws: alice } = await connectAndWait('alice');
|
|
284
|
+
|
|
285
|
+
alice.send(JSON.stringify({
|
|
286
|
+
type: 'announce',
|
|
287
|
+
resource: RESOURCE_HASH,
|
|
288
|
+
offers: []
|
|
289
|
+
}));
|
|
290
|
+
await waitForMessage(alice, 'resource-peers');
|
|
291
|
+
|
|
292
|
+
// Bob joins the resource group
|
|
293
|
+
const { ws: bob } = await connectAndWait('bob');
|
|
294
|
+
bob.send(JSON.stringify({
|
|
295
|
+
type: 'announce',
|
|
296
|
+
resource: RESOURCE_HASH,
|
|
297
|
+
offers: []
|
|
298
|
+
}));
|
|
299
|
+
const bobPeers = await waitForMessage(bob, 'resource-peers');
|
|
300
|
+
assert.strictEqual(bobPeers.count, 1); // alice is there
|
|
301
|
+
|
|
302
|
+
// Alice disconnects
|
|
303
|
+
alice.close();
|
|
304
|
+
await new Promise(r => setTimeout(r, 200));
|
|
305
|
+
|
|
306
|
+
// Charlie joins — should see only bob
|
|
307
|
+
const { ws: charlie } = await connectAndWait('bob'); // reuse bob pod
|
|
308
|
+
charlie.send(JSON.stringify({
|
|
309
|
+
type: 'announce',
|
|
310
|
+
resource: RESOURCE_HASH,
|
|
311
|
+
offers: []
|
|
312
|
+
}));
|
|
313
|
+
const charliePeers = await waitForMessage(charlie, 'resource-peers');
|
|
314
|
+
// bob was reconnected (old connection closed), so count depends on timing
|
|
315
|
+
assert.ok(charliePeers.count >= 0);
|
|
316
|
+
|
|
317
|
+
bob.close();
|
|
318
|
+
charlie.close();
|
|
319
|
+
await new Promise(r => setTimeout(r, 50));
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('should handle leave message', async () => {
|
|
323
|
+
const { ws: alice } = await connectAndWait('alice');
|
|
324
|
+
|
|
325
|
+
alice.send(JSON.stringify({
|
|
326
|
+
type: 'announce',
|
|
327
|
+
resource: RESOURCE_HASH,
|
|
328
|
+
offers: []
|
|
329
|
+
}));
|
|
330
|
+
await waitForMessage(alice, 'resource-peers');
|
|
331
|
+
|
|
332
|
+
// Leave the resource group
|
|
333
|
+
alice.send(JSON.stringify({ type: 'leave', resource: RESOURCE_HASH }));
|
|
334
|
+
|
|
335
|
+
// Bob joins — should see 0 peers (alice left)
|
|
336
|
+
const { ws: bob } = await connectAndWait('bob');
|
|
337
|
+
bob.send(JSON.stringify({
|
|
338
|
+
type: 'announce',
|
|
339
|
+
resource: RESOURCE_HASH,
|
|
340
|
+
offers: []
|
|
341
|
+
}));
|
|
342
|
+
const msg = await waitForMessage(bob, 'resource-peers');
|
|
343
|
+
assert.strictEqual(msg.count, 0);
|
|
344
|
+
|
|
345
|
+
alice.close();
|
|
346
|
+
bob.close();
|
|
347
|
+
await new Promise(r => setTimeout(r, 50));
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('should reject invalid resource hash', async () => {
|
|
351
|
+
const { ws: alice } = await connectAndWait('alice');
|
|
352
|
+
|
|
353
|
+
alice.send(JSON.stringify({
|
|
354
|
+
type: 'announce',
|
|
355
|
+
resource: 'not-a-hex-hash!',
|
|
356
|
+
offers: []
|
|
357
|
+
}));
|
|
358
|
+
|
|
359
|
+
const err = await waitForMessage(alice, 'error');
|
|
360
|
+
assert.ok(err.message.includes('Invalid resource hash'));
|
|
361
|
+
|
|
362
|
+
alice.close();
|
|
363
|
+
await new Promise(r => setTimeout(r, 50));
|
|
364
|
+
});
|
|
365
|
+
});
|
|
212
366
|
});
|