Skip to content

Commit 1e558a9

Browse files
Merge pull request #9 from JavaScriptSolidServer/fix/mashlib-cdn-race-condition
fix(mashlib): CDN race condition with script.onload pattern
2 parents f00e279 + cd2938c commit 1e558a9

10 files changed

Lines changed: 127 additions & 67 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,6 @@ test-nostr-acl.js
144144
test-dpop-flow.js
145145
cth-config/
146146
test-data-idp-accounts/
147+
148+
# Local mashlib build (for development)
149+
src/mashlib-local/

README.md

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ npm run benchmark
5454

5555
## Features
5656

57-
### Implemented (v0.0.17)
57+
### Implemented (v0.0.23)
5858

5959
- **LDP CRUD Operations** - GET, PUT, POST, DELETE, HEAD
6060
- **N3 Patch** - Solid's native patch format for RDF updates
@@ -66,11 +66,13 @@ npm run benchmark
6666
- **Container Management** - Create, list, and manage containers
6767
- **Multi-user Pods** - Path-based (`/alice/`) or subdomain-based (`alice.example.com`)
6868
- **Subdomain Mode** - XSS protection via origin isolation
69-
- **Mashlib Data Browser** - Optional SolidOS UI for browsing RDF resources
69+
- **Mashlib Data Browser** - Optional SolidOS UI (CDN or local hosting)
7070
- **WebID Profiles** - JSON-LD structured data in HTML at pod root
7171
- **Web Access Control (WAC)** - `.acl` file-based authorization
7272
- **Solid-OIDC Identity Provider** - Built-in IdP with DPoP, dynamic registration
7373
- **Solid-OIDC Resource Server** - Accept DPoP-bound access tokens from external IdPs
74+
- **NSS-style Registration** - Username/password auth compatible with Solid apps
75+
- **Nostr Authentication** - NIP-98 HTTP Auth with Schnorr signatures
7476
- **Simple Auth Tokens** - Built-in token authentication for development
7577
- **Content Negotiation** - Optional Turtle <-> JSON-LD conversion
7678
- **CORS Support** - Full cross-origin resource sharing
@@ -139,8 +141,9 @@ jss --help # Show help
139141
| `--idp-issuer <url>` | IdP issuer URL | (auto) |
140142
| `--subdomains` | Enable subdomain-based pods | false |
141143
| `--base-domain <domain>` | Base domain for subdomains | - |
142-
| `--mashlib` | Enable Mashlib data browser | false |
143-
| `--mashlib-version <ver>` | Mashlib version | 2.0.0 |
144+
| `--mashlib` | Enable Mashlib (local mode) | false |
145+
| `--mashlib-cdn` | Enable Mashlib (CDN mode) | false |
146+
| `--mashlib-version <ver>` | Mashlib CDN version | 2.0.0 |
144147
| `-q, --quiet` | Suppress logs | false |
145148

146149
### Environment Variables
@@ -407,24 +410,35 @@ createServer({
407410
notifications: false, // Enable WebSocket notifications (default: false)
408411
subdomains: false, // Enable subdomain-based pods (default: false)
409412
baseDomain: null, // Base domain for subdomains (e.g., "example.com")
410-
mashlib: false, // Enable Mashlib data browser (default: false)
411-
mashlibVersion: '2.0.0', // Mashlib version to use
413+
mashlib: false, // Enable Mashlib data browser - local mode (default: false)
414+
mashlibCdn: false, // Enable Mashlib data browser - CDN mode (default: false)
415+
mashlibVersion: '2.0.0', // Mashlib version for CDN mode
412416
});
413417
```
414418

415419
### Mashlib Data Browser
416420

417-
Enable the [SolidOS Mashlib](https://github.com/SolidOS/mashlib) data browser for RDF resources:
421+
Enable the [SolidOS Mashlib](https://github.com/SolidOS/mashlib) data browser for RDF resources. Two modes are available:
418422

423+
**CDN Mode** (recommended for getting started):
419424
```bash
420-
jss start --mashlib --conneg
425+
jss start --mashlib-cdn --conneg
421426
```
427+
Loads mashlib from unpkg.com CDN. Zero footprint - no local files needed.
422428

423-
When enabled, requesting an RDF resource with `Accept: text/html` returns an interactive data browser UI instead of raw data. Mashlib is loaded from the unpkg CDN.
429+
**Local Mode** (for production/offline):
430+
```bash
431+
jss start --mashlib --conneg
432+
```
433+
Serves mashlib from `src/mashlib-local/dist/`. Requires building mashlib locally:
434+
```bash
435+
cd src/mashlib-local
436+
npm install && npm run build
437+
```
424438

425439
**How it works:**
426440
1. Browser requests `/alice/public/data.ttl` with `Accept: text/html`
427-
2. Server returns Mashlib HTML wrapper (loads JS/CSS from CDN)
441+
2. Server returns Mashlib HTML wrapper
428442
3. Mashlib fetches the actual data via content negotiation
429443
4. Mashlib renders an interactive, editable view
430444

bin/jss.js

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,10 @@ program
5050
.option('--subdomains', 'Enable subdomain-based pods (XSS protection)')
5151
.option('--no-subdomains', 'Disable subdomain-based pods')
5252
.option('--base-domain <domain>', 'Base domain for subdomain pods (e.g., "example.com")')
53-
.option('--mashlib', 'Enable Mashlib data browser for RDF resources')
53+
.option('--mashlib', 'Enable Mashlib data browser (local mode, requires mashlib in node_modules)')
54+
.option('--mashlib-cdn', 'Enable Mashlib data browser (CDN mode, no local files needed)')
5455
.option('--no-mashlib', 'Disable Mashlib data browser')
55-
.option('--mashlib-version <version>', 'Mashlib version to use (default: 2.0.0)')
56+
.option('--mashlib-version <version>', 'Mashlib version for CDN mode (default: 2.0.0)')
5657
.option('-q, --quiet', 'Suppress log output')
5758
.option('--print-config', 'Print configuration and exit')
5859
.action(async (options) => {
@@ -91,7 +92,8 @@ program
9192
root: config.root,
9293
subdomains: config.subdomains,
9394
baseDomain: config.baseDomain,
94-
mashlib: config.mashlib,
95+
mashlib: config.mashlib || config.mashlibCdn,
96+
mashlibCdn: config.mashlibCdn,
9597
mashlibVersion: config.mashlibVersion,
9698
});
9799

@@ -106,7 +108,11 @@ program
106108
if (config.notifications) console.log(' WebSocket: enabled');
107109
if (config.idp) console.log(` IdP: ${idpIssuer}`);
108110
if (config.subdomains) console.log(` Subdomains: ${config.baseDomain} (XSS protection enabled)`);
109-
if (config.mashlib) console.log(` Mashlib: v${config.mashlibVersion} (data browser enabled)`);
111+
if (config.mashlibCdn) {
112+
console.log(` Mashlib: v${config.mashlibVersion} (CDN mode)`);
113+
} else if (config.mashlib) {
114+
console.log(` Mashlib: local (data browser enabled)`);
115+
}
110116
console.log('\n Press Ctrl+C to stop\n');
111117
}
112118

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "javascript-solid-server",
3-
"version": "0.0.22",
3+
"version": "0.0.23",
44
"description": "A minimal, fast Solid server",
55
"main": "src/index.js",
66
"type": "module",

src/config.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export const defaults = {
3939

4040
// Mashlib data browser
4141
mashlib: false,
42+
mashlibCdn: false,
4243
mashlibVersion: '2.0.0',
4344

4445
// Logging
@@ -68,6 +69,7 @@ const envMap = {
6869
JSS_SUBDOMAINS: 'subdomains',
6970
JSS_BASE_DOMAIN: 'baseDomain',
7071
JSS_MASHLIB: 'mashlib',
72+
JSS_MASHLIB_CDN: 'mashlibCdn',
7173
JSS_MASHLIB_VERSION: 'mashlibVersion',
7274
};
7375

@@ -201,6 +203,6 @@ export function printConfig(config) {
201203
console.log(` Notifications: ${config.notifications}`);
202204
console.log(` IdP: ${config.idp ? (config.idpIssuer || 'enabled') : 'disabled'}`);
203205
console.log(` Subdomains: ${config.subdomains ? (config.baseDomain || 'enabled') : 'disabled'}`);
204-
console.log(` Mashlib: ${config.mashlib ? `v${config.mashlibVersion}` : 'disabled'}`);
206+
console.log(` Mashlib: ${config.mashlibCdn ? `CDN v${config.mashlibVersion}` : config.mashlib ? 'local' : 'disabled'}`);
205207
console.log('─'.repeat(40));
206208
}

src/handlers/resource.js

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,9 @@ export async function handleGet(request, reply) {
145145
// Check if we should serve Mashlib data browser
146146
// Only for RDF resources when Accept: text/html is requested
147147
if (shouldServeMashlib(request, request.mashlibEnabled, storedContentType)) {
148-
const html = generateDatabrowserHtml(resourceUrl, request.mashlibVersion);
148+
// Pass CDN version if using CDN mode, null for local mode
149+
const cdnVersion = request.mashlibCdn ? request.mashlibVersion : null;
150+
const html = generateDatabrowserHtml(resourceUrl, cdnVersion);
149151
const headers = getAllHeaders({
150152
isContainer: false,
151153
etag: stats.etag,
@@ -155,6 +157,10 @@ export async function handleGet(request, reply) {
155157
connegEnabled
156158
});
157159
headers['Vary'] = 'Accept';
160+
headers['X-Frame-Options'] = 'DENY';
161+
headers['Content-Security-Policy'] = "frame-ancestors 'none'";
162+
// Don't cache the HTML wrapper - always negotiate fresh
163+
headers['Cache-Control'] = 'no-store';
158164

159165
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
160166
return reply.type('text/html').send(html);
@@ -191,7 +197,7 @@ export async function handleGet(request, reply) {
191197
resourceUrl,
192198
connegEnabled
193199
});
194-
headers['Vary'] = getVaryHeader(connegEnabled);
200+
headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled);
195201

196202
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
197203
return reply.send(outputContent);
@@ -209,7 +215,7 @@ export async function handleGet(request, reply) {
209215
resourceUrl,
210216
connegEnabled
211217
});
212-
headers['Vary'] = getVaryHeader(connegEnabled);
218+
headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled);
213219

214220
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
215221
return reply.send(content);
@@ -353,7 +359,7 @@ export async function handlePut(request, reply) {
353359
const origin = request.headers.origin;
354360
const headers = getAllHeaders({ isContainer: false, origin, resourceUrl, connegEnabled });
355361
headers['Location'] = resourceUrl;
356-
headers['Vary'] = getVaryHeader(connegEnabled);
362+
headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled);
357363

358364
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
359365

src/mashlib/index.js

Lines changed: 34 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -6,51 +6,38 @@
66
* we return this wrapper which then fetches and renders the data.
77
*/
88

9-
const CDN_BASE = 'https://unpkg.com/mashlib';
10-
119
/**
1210
* Generate Mashlib databrowser HTML
13-
* @param {string} resourceUrl - The URL of the resource being viewed
14-
* @param {string} version - Mashlib version (default: '2.0.0')
11+
*
12+
* @param {string} resourceUrl - The URL of the resource being viewed (unused, kept for API compatibility)
13+
* @param {string} cdnVersion - If provided, load mashlib from unpkg CDN (e.g., "2.0.0")
1514
* @returns {string} HTML content
1615
*/
17-
export function generateDatabrowserHtml(resourceUrl, version = '2.0.0') {
18-
const cdnUrl = `${CDN_BASE}@${version}/dist`;
16+
export function generateDatabrowserHtml(resourceUrl, cdnVersion = null) {
17+
if (cdnVersion) {
18+
// CDN mode - use script.onload to ensure mashlib is fully loaded before init
19+
// This avoids race conditions with defer + DOMContentLoaded
20+
const cdnBase = `https://unpkg.com/mashlib@${cdnVersion}/dist`;
21+
return `<!doctype html><html><head><meta charset="utf-8"/><title>SolidOS Web App</title>
22+
<link href="${cdnBase}/mash.css" rel="stylesheet"></head>
23+
<body id="PageBody"><header id="PageHeader"></header>
24+
<div class="TabulatorOutline" id="DummyUUID" role="main"><table id="outline"></table><div id="GlobalDashboard"></div></div>
25+
<footer id="PageFooter"></footer>
26+
<script>
27+
(function() {
28+
var s = document.createElement('script');
29+
s.src = '${cdnBase}/mashlib.min.js';
30+
s.onload = function() { panes.runDataBrowser(); };
31+
s.onerror = function() { document.body.innerHTML = '<p>Failed to load Mashlib from CDN</p>'; };
32+
document.head.appendChild(s);
33+
})();
34+
</script></body></html>`;
35+
}
1936

20-
return `<!doctype html>
21-
<html>
22-
<head>
23-
<meta charset="utf-8"/>
24-
<meta name="viewport" content="width=device-width, initial-scale=1">
25-
<title>SolidOS - ${escapeHtml(resourceUrl)}</title>
26-
<script defer src="${cdnUrl}/mashlib.min.js"></script>
27-
<link href="${cdnUrl}/mash.css" rel="stylesheet">
28-
<script>
29-
document.addEventListener('DOMContentLoaded', function() {
30-
// runDataBrowser uses window.location to determine what to fetch
31-
panes.runDataBrowser();
32-
});
33-
</script>
34-
<style>
35-
/* Loading indicator */
36-
body:not(.loaded) #PageBody::before {
37-
content: 'Loading SolidOS...';
38-
display: block;
39-
padding: 2em;
40-
text-align: center;
41-
color: #666;
42-
}
43-
</style>
44-
</head>
45-
<body id="PageBody">
46-
<header id="PageHeader"></header>
47-
<div class="TabulatorOutline" id="DummyUUID" role="main">
48-
<table id="outline"></table>
49-
<div id="GlobalDashboard"></div>
50-
</div>
51-
<footer id="PageFooter"></footer>
52-
</body>
53-
</html>`;
37+
// Local mode - use defer (reliable when served locally)
38+
return `<!doctype html><html><head><meta charset="utf-8"/><title>SolidOS Web App</title><script>document.addEventListener('DOMContentLoaded', function() {
39+
panes.runDataBrowser()
40+
})</script><script defer="defer" src="/mashlib.min.js"></script><link href="/mash.css" rel="stylesheet"></head><body id="PageBody"><header id="PageHeader"></header><div class="TabulatorOutline" id="DummyUUID" role="main"><table id="outline"></table><div id="GlobalDashboard"></div></div><footer id="PageFooter"></footer></body></html>`;
5441
}
5542

5643
/**
@@ -61,11 +48,17 @@ export function generateDatabrowserHtml(resourceUrl, version = '2.0.0') {
6148
* @returns {boolean}
6249
*/
6350
export function shouldServeMashlib(request, mashlibEnabled, contentType) {
51+
const accept = request.headers.accept || '';
52+
const secFetchDest = request.headers['sec-fetch-dest'] || '';
53+
6454
if (!mashlibEnabled) {
6555
return false;
6656
}
6757

68-
const accept = request.headers.accept || '';
58+
// Don't serve mashlib for iframe/embed requests (prevents recursive loop)
59+
if (secFetchDest === 'iframe' || secFetchDest === 'embed' || secFetchDest === 'object') {
60+
return false;
61+
}
6962

7063
// Must explicitly accept HTML
7164
if (!accept.includes('text/html')) {

src/rdf/conneg.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,9 +188,10 @@ export async function fromJsonLd(jsonLd, targetType, baseUri, connegEnabled = fa
188188

189189
/**
190190
* Get Vary header value for content negotiation
191+
* Include Accept when conneg or mashlib is enabled (response varies by Accept header)
191192
*/
192-
export function getVaryHeader(connegEnabled) {
193-
return connegEnabled ? 'Accept, Origin' : 'Origin';
193+
export function getVaryHeader(connegEnabled, mashlibEnabled = false) {
194+
return (connegEnabled || mashlibEnabled) ? 'Accept, Origin' : 'Origin';
194195
}
195196

196197
/**

0 commit comments

Comments
 (0)