Skip to content

Commit a7c43b6

Browse files
fix(mashlib): CDN race condition with script.onload pattern
The defer + DOMContentLoaded pattern doesn't reliably wait for CDN scripts to load. When mashlib is loaded from unpkg.com or other CDNs, DOMContentLoaded can fire before the script finishes downloading, causing panes.runDataBrowser() to fail. Changes: - Add --mashlib-cdn flag for CDN mode (zero footprint) - CDN mode uses script.onload to guarantee mashlib is loaded - Local mode (--mashlib) unchanged, uses official databrowser.html - Add Cache-Control: no-store to prevent HTML wrapper caching - Add onerror handler for graceful CDN failure Fixes #8 Related: SolidOS/mashlib#260
1 parent f00e279 commit a7c43b6

5 files changed

Lines changed: 91 additions & 49 deletions

File tree

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

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: 7 additions & 1 deletion
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);

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/server.js

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import Fastify from 'fastify';
2+
import { readFile } from 'fs/promises';
3+
import { join, dirname } from 'path';
4+
import { fileURLToPath } from 'url';
25
import { handleGet, handleHead, handlePut, handleDelete, handleOptions, handlePatch } from './handlers/resource.js';
36
import { handlePost, handleCreatePod } from './handlers/container.js';
47
import { getCorsHeaders } from './ldp/headers.js';
58
import { authorize, handleUnauthorized } from './auth/middleware.js';
69
import { notificationsPlugin } from './notifications/index.js';
710
import { idpPlugin } from './idp/index.js';
811

12+
const __dirname = dirname(fileURLToPath(import.meta.url));
13+
914
/**
1015
* Create and configure Fastify server
1116
* @param {object} options - Server options
@@ -31,7 +36,9 @@ export function createServer(options = {}) {
3136
const subdomainsEnabled = options.subdomains ?? false;
3237
const baseDomain = options.baseDomain || null;
3338
// Mashlib data browser is OFF by default
39+
// mashlibCdn: if true, load from CDN; if false, serve locally
3440
const mashlibEnabled = options.mashlib ?? false;
41+
const mashlibCdn = options.mashlibCdn ?? false;
3542
const mashlibVersion = options.mashlibVersion ?? '2.0.0';
3643

3744
// Set data root via environment variable if provided
@@ -70,6 +77,7 @@ export function createServer(options = {}) {
7077
fastify.decorateRequest('baseDomain', null);
7178
fastify.decorateRequest('podName', null);
7279
fastify.decorateRequest('mashlibEnabled', null);
80+
fastify.decorateRequest('mashlibCdn', null);
7381
fastify.decorateRequest('mashlibVersion', null);
7482
fastify.addHook('onRequest', async (request) => {
7583
request.connegEnabled = connegEnabled;
@@ -78,6 +86,7 @@ export function createServer(options = {}) {
7886
request.subdomainsEnabled = subdomainsEnabled;
7987
request.baseDomain = baseDomain;
8088
request.mashlibEnabled = mashlibEnabled;
89+
request.mashlibCdn = mashlibCdn;
8190
request.mashlibVersion = mashlibVersion;
8291

8392
// Extract pod name from subdomain if enabled
@@ -122,11 +131,13 @@ export function createServer(options = {}) {
122131
// Authorization hook - check WAC permissions
123132
// Skip for pod creation endpoint (needs special handling)
124133
fastify.addHook('preHandler', async (request, reply) => {
125-
// Skip auth for pod creation, OPTIONS, IdP routes, and well-known endpoints
134+
// Skip auth for pod creation, OPTIONS, IdP routes, mashlib, and well-known endpoints
135+
const mashlibPaths = ['/mashlib.min.js', '/mash.css', '/841.mashlib.min.js'];
126136
if (request.url === '/.pods' ||
127137
request.method === 'OPTIONS' ||
128138
request.url.startsWith('/idp/') ||
129-
request.url.startsWith('/.well-known/')) {
139+
request.url.startsWith('/.well-known/') ||
140+
mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) {
130141
return;
131142
}
132143

@@ -144,6 +155,30 @@ export function createServer(options = {}) {
144155
// Pod creation endpoint
145156
fastify.post('/.pods', handleCreatePod);
146157

158+
// Mashlib static files (served from root like NSS does)
159+
if (mashlibEnabled) {
160+
const mashlibDir = join(__dirname, 'mashlib-local', 'dist');
161+
const mashlibFiles = {
162+
'/mashlib.min.js': { file: 'mashlib.min.js', type: 'application/javascript' },
163+
'/mashlib.min.js.map': { file: 'mashlib.min.js.map', type: 'application/json' },
164+
'/mash.css': { file: 'mash.css', type: 'text/css' },
165+
'/mash.css.map': { file: 'mash.css.map', type: 'application/json' },
166+
'/841.mashlib.min.js': { file: '841.mashlib.min.js', type: 'application/javascript' },
167+
'/841.mashlib.min.js.map': { file: '841.mashlib.min.js.map', type: 'application/json' }
168+
};
169+
170+
for (const [path, config] of Object.entries(mashlibFiles)) {
171+
fastify.get(path, async (request, reply) => {
172+
try {
173+
const content = await readFile(join(mashlibDir, config.file));
174+
return reply.type(config.type).send(content);
175+
} catch {
176+
return reply.code(404).send({ error: 'Not Found' });
177+
}
178+
});
179+
}
180+
}
181+
147182
// LDP routes - using wildcard routing
148183
fastify.get('/*', handleGet);
149184
fastify.head('/*', handleHead);

0 commit comments

Comments
 (0)