javascript-solid-server 0.0.83 → 0.0.85
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 +4 -1
- package/bin/jss.js +2 -0
- package/package.json +1 -1
- package/src/config.js +4 -0
- package/src/handlers/resource.js +30 -0
- package/src/notifications/events.js +55 -0
- package/src/server.js +18 -3
|
@@ -320,7 +320,10 @@
|
|
|
320
320
|
"WebFetch(domain:remotestorage.io)",
|
|
321
321
|
"Bash(gh issue create --title \"Feature: --public flag to skip WAC and allow open access\" --body \"$\\(cat << ''ENDOFFILE''\n## Summary\n\nAdd a `--public` flag that disables WAC \\(Web Access Control\\) checks, allowing unauthenticated read/write access to all resources. This enables JSS to be used as a simple file server similar to `npx serve`, but with REST write capabilities.\n\n**Difficulty**: 15/100 \n**Estimated Effort**: 2-4 hours \n**Dependencies**: None\n\n---\n\n## Motivation\n\nCurrently, JSS requires either:\n1. ACL files to grant access, or\n2. Authentication via Solid-OIDC\n\nThis makes it impossible to use JSS as a simple \"just serve this folder\" tool like `npx serve`. The `--public` flag would enable:\n\n1. **Quick local development** - No auth setup needed\n2. **Simple file sharing** - LAN file server with write support\n3. **jsserve wrapper** - Foundation for `npx jsserve` \\(see future issue\\)\n4. **WebDAV alternative** - Simple REST-based file server\n5. **Testing/demos** - Quick Solid server without auth complexity\n\n---\n\n## Proposed Implementation\n\n### CLI Flag\n\n```bash\njss start --public # Open read/write, no auth\njss start --public --read-only # Open read, no writes \\(like npx serve\\)\n```\n\n### Environment Variable\n\n```bash\nJSS_PUBLIC=true jss start\n```\n\n### Config File\n\n```json\n{\n \"public\": true\n}\n```\n\n---\n\n## Implementation Details\n\n### 1. Add flag to CLI \\(`bin/jss.js`\\)\n\n```javascript\n.option\\(''--public'', ''Allow unauthenticated access to all resources \\(disables WAC\\)''\\)\n.option\\(''--read-only'', ''Disable PUT/DELETE methods \\(read-only mode\\)''\\)\n```\n\n### 2. Add to config \\(`src/config.js`\\)\n\n```javascript\nconst DEFAULTS = {\n // ... existing ...\n public: false,\n readOnly: false,\n};\n\n// Environment variable mapping\nif \\(process.env.JSS_PUBLIC\\) {\n config.public = process.env.JSS_PUBLIC === ''true'';\n}\nif \\(process.env.JSS_READ_ONLY\\) {\n config.readOnly = process.env.JSS_READ_ONLY === ''true'';\n}\n```\n\n### 3. Skip WAC when public \\(`src/auth/middleware.js`\\)\n\n```javascript\nexport async function authorize\\(request, reply\\) {\n // Public mode - skip all auth/WAC checks\n if \\(request.config?.public\\) {\n return; // Allow request to proceed\n }\n \n // ... existing WAC logic ...\n}\n```\n\n### 4. Block writes when read-only \\(`src/handlers/resource.js`, `src/handlers/container.js`\\)\n\n```javascript\n// At start of PUT/DELETE handlers\nif \\(request.config?.readOnly\\) {\n return reply.code\\(405\\).send\\({ \n error: ''Method Not Allowed'',\n message: ''Server is in read-only mode''\n }\\);\n}\n```\n\n---\n\n## Behavior Matrix\n\n| Flag Combination | GET | PUT/DELETE | Auth Required |\n|------------------|-----|------------|---------------|\n| \\(default\\) | ACL | ACL | Yes \\(if ACL requires\\) |\n| `--public` | ✅ Allow | ✅ Allow | No |\n| `--public --read-only` | ✅ Allow | ❌ Block | No |\n| `--read-only` \\(no public\\) | ACL | ❌ Block | Yes |\n\n---\n\n## Security Considerations\n\n### Warning on Startup\n\nWhen `--public` is enabled, show a clear warning:\n\n```\n⚠️ WARNING: Server running in PUBLIC mode\n All files are readable and writable without authentication.\n Do not use in production or expose to the internet.\n```\n\n### Binding to localhost by default?\n\nConsider: When `--public` is set, should the default host be `localhost` instead of `0.0.0.0`?\n\n```javascript\nif \\(config.public && !explicitHostSet\\) {\n config.host = ''localhost''; // Safer default for public mode\n}\n```\n\nUser can override with `--public --host 0.0.0.0` if they explicitly want network access.\n\n---\n\n## Examples\n\n### Local Development\n```bash\n# Quick Solid-compatible file server for development\njss start --public --port 3000 --root ./test-data\n```\n\n### Read-Only File Sharing\n```bash\n# Share files on LAN, no writes allowed\njss start --public --read-only --host 0.0.0.0 --root ~/shared\n```\n\n### Testing Solid Apps\n```bash\n# Test app without auth complexity\njss start --public --root ./fixtures\nnpm test\n```\n\n---\n\n## Files to Modify\n\n| File | Changes |\n|------|---------|\n| `bin/jss.js` | Add `--public` and `--read-only` options \\(~5 LOC\\) |\n| `src/config.js` | Add defaults and env var mapping \\(~10 LOC\\) |\n| `src/auth/middleware.js` | Skip WAC when public \\(~5 LOC\\) |\n| `src/handlers/resource.js` | Block writes when read-only \\(~5 LOC\\) |\n| `src/handlers/container.js` | Block writes when read-only \\(~5 LOC\\) |\n| **Total** | **~30 LOC** |\n\n---\n\n## Testing\n\n```javascript\ndescribe\\(''--public flag'', \\(\\) => {\n it\\(''should allow unauthenticated GET'', async \\(\\) => {\n const server = await createServer\\({ public: true, root: tmpDir }\\);\n const res = await request\\(server\\).get\\(''/file.txt''\\);\n expect\\(res.status\\).toBe\\(200\\);\n }\\);\n\n it\\(''should allow unauthenticated PUT'', async \\(\\) => {\n const server = await createServer\\({ public: true, root: tmpDir }\\);\n const res = await request\\(server\\)\n .put\\(''/new-file.txt''\\)\n .send\\(''content''\\);\n expect\\(res.status\\).toBe\\(201\\);\n }\\);\n\n it\\(''should block PUT when read-only'', async \\(\\) => {\n const server = await createServer\\({ public: true, readOnly: true, root: tmpDir }\\);\n const res = await request\\(server\\)\n .put\\(''/new-file.txt''\\)\n .send\\(''content''\\);\n expect\\(res.status\\).toBe\\(405\\);\n }\\);\n}\\);\n```\n\n---\n\n## Related Issues\n\n- Future: `jsserve` package \\(thin wrapper using this flag\\)\n- #100 - Production Readiness \\(this is a dev/convenience feature\\)\n\n---\n\n## Open Questions\n\n1. Should `--public` default to `localhost` binding for safety?\n2. Should there be a `--public-read` \\(read-only public\\) shorthand?\n3. Should `--public` disable IdP/login UI entirely, or just make it optional?\nENDOFFILE\n\\)\")",
|
|
322
322
|
"Bash(npx serve --help:*)",
|
|
323
|
-
"Bash(npm exec serve:*)"
|
|
323
|
+
"Bash(npm exec serve:*)",
|
|
324
|
+
"Bash(npm link)",
|
|
325
|
+
"Bash(npm link:*)",
|
|
326
|
+
"Bash(git push)"
|
|
324
327
|
]
|
|
325
328
|
}
|
|
326
329
|
}
|
package/bin/jss.js
CHANGED
|
@@ -78,6 +78,7 @@ program
|
|
|
78
78
|
.option('--no-webid-tls', 'Disable WebID-TLS authentication')
|
|
79
79
|
.option('--public', 'Allow unauthenticated access (skip WAC, open read/write)')
|
|
80
80
|
.option('--read-only', 'Disable PUT/DELETE/PATCH methods (read-only mode)')
|
|
81
|
+
.option('--live-reload', 'Inject live reload script into HTML (auto-refresh on changes)')
|
|
81
82
|
.option('-q, --quiet', 'Suppress log output')
|
|
82
83
|
.option('--print-config', 'Print configuration and exit')
|
|
83
84
|
.action(async (options) => {
|
|
@@ -135,6 +136,7 @@ program
|
|
|
135
136
|
singleUserName: config.singleUserName,
|
|
136
137
|
public: config.public,
|
|
137
138
|
readOnly: config.readOnly,
|
|
139
|
+
liveReload: config.liveReload,
|
|
138
140
|
});
|
|
139
141
|
|
|
140
142
|
await server.listen({ port: config.port, host: config.host });
|
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -79,6 +79,9 @@ export const defaults = {
|
|
|
79
79
|
// Read-only mode - disable PUT/DELETE/PATCH
|
|
80
80
|
readOnly: false,
|
|
81
81
|
|
|
82
|
+
// Live reload - inject script to auto-refresh browser on file changes
|
|
83
|
+
liveReload: false,
|
|
84
|
+
|
|
82
85
|
// Logging
|
|
83
86
|
logger: true,
|
|
84
87
|
quiet: false,
|
|
@@ -125,6 +128,7 @@ const envMap = {
|
|
|
125
128
|
JSS_DEFAULT_QUOTA: 'defaultQuota',
|
|
126
129
|
JSS_PUBLIC: 'public',
|
|
127
130
|
JSS_READ_ONLY: 'readOnly',
|
|
131
|
+
JSS_LIVE_RELOAD: 'liveReload',
|
|
128
132
|
};
|
|
129
133
|
|
|
130
134
|
/**
|
package/src/handlers/resource.js
CHANGED
|
@@ -17,6 +17,23 @@ import { emitChange } from '../notifications/events.js';
|
|
|
17
17
|
import { checkIfMatch, checkIfNoneMatchForGet, checkIfNoneMatchForWrite } from '../utils/conditional.js';
|
|
18
18
|
import { generateDatabrowserHtml, generateSolidosUiHtml, shouldServeMashlib } from '../mashlib/index.js';
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Live reload script - injected into HTML when --live-reload is enabled
|
|
22
|
+
*/
|
|
23
|
+
const LIVE_RELOAD_SCRIPT = `<script>(function(){var ws=new WebSocket((location.protocol==='https:'?'wss:':'ws:')+'//' +location.host+'/.notifications');ws.onopen=function(){ws.send('sub '+location.href)};ws.onmessage=function(e){if(e.data.startsWith('pub '))location.reload()};ws.onclose=function(){setTimeout(function(){location.reload()},1000)}})();</script>`;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Inject live reload script into HTML content
|
|
27
|
+
*/
|
|
28
|
+
function injectLiveReload(content) {
|
|
29
|
+
const html = content.toString();
|
|
30
|
+
// Inject before </body> or at end
|
|
31
|
+
if (html.includes('</body>')) {
|
|
32
|
+
return Buffer.from(html.replace('</body>', LIVE_RELOAD_SCRIPT + '</body>'));
|
|
33
|
+
}
|
|
34
|
+
return Buffer.from(html + LIVE_RELOAD_SCRIPT);
|
|
35
|
+
}
|
|
36
|
+
|
|
20
37
|
/**
|
|
21
38
|
* Get the storage path and resource URL for a request
|
|
22
39
|
* In subdomain mode, storage path includes pod name, URL uses subdomain
|
|
@@ -198,6 +215,12 @@ export async function handleGet(request, reply) {
|
|
|
198
215
|
});
|
|
199
216
|
|
|
200
217
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
218
|
+
// Inject live reload script for index.html
|
|
219
|
+
if (request.liveReloadEnabled) {
|
|
220
|
+
reply.header('Cache-Control', 'no-store');
|
|
221
|
+
reply.removeHeader('ETag');
|
|
222
|
+
return reply.send(injectLiveReload(content));
|
|
223
|
+
}
|
|
201
224
|
return reply.send(content);
|
|
202
225
|
}
|
|
203
226
|
|
|
@@ -439,6 +462,13 @@ export async function handleGet(request, reply) {
|
|
|
439
462
|
headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled);
|
|
440
463
|
|
|
441
464
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
465
|
+
|
|
466
|
+
// Inject live reload script into HTML (disable caching since content is modified)
|
|
467
|
+
if (actualContentType === 'text/html' && request.liveReloadEnabled) {
|
|
468
|
+
reply.header('Cache-Control', 'no-store');
|
|
469
|
+
reply.removeHeader('ETag');
|
|
470
|
+
return reply.send(injectLiveReload(content));
|
|
471
|
+
}
|
|
442
472
|
return reply.send(content);
|
|
443
473
|
}
|
|
444
474
|
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { EventEmitter } from 'events';
|
|
9
|
+
import { watch } from 'fs';
|
|
10
|
+
import { join, relative } from 'path';
|
|
9
11
|
|
|
10
12
|
// Singleton event emitter for resource changes
|
|
11
13
|
export const resourceEvents = new EventEmitter();
|
|
@@ -20,3 +22,56 @@ resourceEvents.setMaxListeners(1000);
|
|
|
20
22
|
export function emitChange(resourceUrl) {
|
|
21
23
|
resourceEvents.emit('change', resourceUrl);
|
|
22
24
|
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Start watching filesystem for changes and emit notifications
|
|
28
|
+
* @param {string} rootDir - Directory to watch
|
|
29
|
+
* @param {string} baseUrl - Base URL for constructing resource URLs (e.g., http://localhost:3000)
|
|
30
|
+
*/
|
|
31
|
+
export function startFileWatcher(rootDir, baseUrl) {
|
|
32
|
+
// Debounce map to avoid duplicate events (editors often save multiple times)
|
|
33
|
+
const debounceMap = new Map();
|
|
34
|
+
const DEBOUNCE_MS = 100;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const watcher = watch(rootDir, { recursive: true }, (eventType, filename) => {
|
|
38
|
+
if (!filename) return;
|
|
39
|
+
|
|
40
|
+
// Skip hidden files and common temp files
|
|
41
|
+
if (filename.startsWith('.') || filename.endsWith('~') || filename.endsWith('.swp')) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Debounce: skip if we just emitted for this file
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
const lastEmit = debounceMap.get(filename);
|
|
48
|
+
if (lastEmit && now - lastEmit < DEBOUNCE_MS) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
debounceMap.set(filename, now);
|
|
52
|
+
|
|
53
|
+
// Clean up old debounce entries periodically
|
|
54
|
+
if (debounceMap.size > 1000) {
|
|
55
|
+
for (const [key, time] of debounceMap) {
|
|
56
|
+
if (now - time > 5000) debounceMap.delete(key);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Construct resource URL
|
|
61
|
+
const resourcePath = '/' + filename.replace(/\\/g, '/');
|
|
62
|
+
const resourceUrl = baseUrl.replace(/\/$/, '') + resourcePath;
|
|
63
|
+
|
|
64
|
+
emitChange(resourceUrl);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Handle watcher errors gracefully
|
|
68
|
+
watcher.on('error', (err) => {
|
|
69
|
+
console.error('File watcher error:', err.message);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return watcher;
|
|
73
|
+
} catch (err) {
|
|
74
|
+
console.error('Failed to start file watcher:', err.message);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/server.js
CHANGED
|
@@ -9,6 +9,7 @@ import * as storage from './storage/filesystem.js';
|
|
|
9
9
|
import { getCorsHeaders } from './ldp/headers.js';
|
|
10
10
|
import { authorize, handleUnauthorized } from './auth/middleware.js';
|
|
11
11
|
import { notificationsPlugin } from './notifications/index.js';
|
|
12
|
+
import { startFileWatcher } from './notifications/events.js';
|
|
12
13
|
import { idpPlugin } from './idp/index.js';
|
|
13
14
|
import { isGitRequest, isGitWriteOperation, handleGit } from './handlers/git.js';
|
|
14
15
|
import { AccessMode } from './wac/parser.js';
|
|
@@ -79,6 +80,8 @@ export function createServer(options = {}) {
|
|
|
79
80
|
const defaultQuota = options.defaultQuota ?? 50 * 1024 * 1024;
|
|
80
81
|
// WebID-TLS client certificate authentication is OFF by default
|
|
81
82
|
const webidTlsEnabled = options.webidTls ?? false;
|
|
83
|
+
// Live reload - injects script to auto-refresh browser on file changes
|
|
84
|
+
const liveReloadEnabled = options.liveReload ?? false;
|
|
82
85
|
|
|
83
86
|
// Set data root via environment variable if provided
|
|
84
87
|
if (options.root) {
|
|
@@ -136,9 +139,10 @@ export function createServer(options = {}) {
|
|
|
136
139
|
fastify.decorateRequest('solidosUiEnabled', null);
|
|
137
140
|
fastify.decorateRequest('defaultQuota', null);
|
|
138
141
|
fastify.decorateRequest('config', null);
|
|
142
|
+
fastify.decorateRequest('liveReloadEnabled', null);
|
|
139
143
|
fastify.addHook('onRequest', async (request) => {
|
|
140
144
|
request.connegEnabled = connegEnabled;
|
|
141
|
-
request.notificationsEnabled = notificationsEnabled;
|
|
145
|
+
request.notificationsEnabled = notificationsEnabled || liveReloadEnabled;
|
|
142
146
|
request.idpEnabled = idpEnabled;
|
|
143
147
|
request.subdomainsEnabled = subdomainsEnabled;
|
|
144
148
|
request.baseDomain = baseDomain;
|
|
@@ -148,6 +152,7 @@ export function createServer(options = {}) {
|
|
|
148
152
|
request.solidosUiEnabled = solidosUiEnabled;
|
|
149
153
|
request.defaultQuota = defaultQuota;
|
|
150
154
|
request.config = { public: options.public, readOnly: options.readOnly };
|
|
155
|
+
request.liveReloadEnabled = liveReloadEnabled;
|
|
151
156
|
|
|
152
157
|
// Extract pod name from subdomain if enabled
|
|
153
158
|
if (subdomainsEnabled && baseDomain) {
|
|
@@ -164,8 +169,8 @@ export function createServer(options = {}) {
|
|
|
164
169
|
}
|
|
165
170
|
});
|
|
166
171
|
|
|
167
|
-
// Register WebSocket notifications plugin if enabled
|
|
168
|
-
if (notificationsEnabled) {
|
|
172
|
+
// Register WebSocket notifications plugin if enabled (or live reload needs it)
|
|
173
|
+
if (notificationsEnabled || liveReloadEnabled) {
|
|
169
174
|
fastify.register(notificationsPlugin);
|
|
170
175
|
}
|
|
171
176
|
|
|
@@ -538,6 +543,16 @@ export function createServer(options = {}) {
|
|
|
538
543
|
// Note: Quota not initialized for root-level pods (no user directory)
|
|
539
544
|
}
|
|
540
545
|
|
|
546
|
+
// Start file watcher for live reload (watches filesystem for external changes)
|
|
547
|
+
if (liveReloadEnabled) {
|
|
548
|
+
const dataRoot = options.root || process.env.DATA_ROOT || './data';
|
|
549
|
+
const protocol = options.ssl ? 'https' : 'http';
|
|
550
|
+
// Use configured port, or default; actual URL will be localhost
|
|
551
|
+
const port = options.port || 3000;
|
|
552
|
+
const baseUrl = `${protocol}://localhost:${port}`;
|
|
553
|
+
startFileWatcher(dataRoot, baseUrl);
|
|
554
|
+
}
|
|
555
|
+
|
|
541
556
|
return fastify;
|
|
542
557
|
}
|
|
543
558
|
|