javascript-solid-server 0.0.9 → 0.0.11
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 +6 -1
- package/README.md +159 -11
- package/benchmark.js +145 -249
- package/bin/jss.js +208 -0
- package/package.json +21 -5
- package/src/config.js +185 -0
- package/src/handlers/resource.js +103 -34
- package/src/patch/sparql-update.js +401 -0
- package/src/server.js +21 -10
- package/src/utils/conditional.js +153 -0
- package/test/conditional.test.js +250 -0
- package/test/conformance.test.js +349 -0
- package/test/sparql-update.test.js +219 -0
package/package.json
CHANGED
|
@@ -1,16 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "javascript-solid-server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.11",
|
|
4
4
|
"description": "A minimal, fast Solid server",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"jss": "./bin/jss.js"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/JavaScriptSolidServer/JavaScriptSolidServer.git"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/JavaScriptSolidServer/JavaScriptSolidServer#readme",
|
|
7
18
|
"scripts": {
|
|
8
|
-
"start": "node
|
|
9
|
-
"dev": "node --watch
|
|
10
|
-
"test": "node --test --test-concurrency=1"
|
|
19
|
+
"start": "node bin/jss.js start",
|
|
20
|
+
"dev": "node --watch bin/jss.js start",
|
|
21
|
+
"test": "node --test --test-concurrency=1",
|
|
22
|
+
"benchmark": "node benchmark.js"
|
|
11
23
|
},
|
|
12
24
|
"dependencies": {
|
|
13
25
|
"@fastify/websocket": "^8.3.1",
|
|
26
|
+
"commander": "^14.0.2",
|
|
14
27
|
"fastify": "^4.25.2",
|
|
15
28
|
"fs-extra": "^11.2.0",
|
|
16
29
|
"jose": "^6.1.3",
|
|
@@ -25,5 +38,8 @@
|
|
|
25
38
|
"linked-data",
|
|
26
39
|
"decentralized"
|
|
27
40
|
],
|
|
28
|
-
"license": "MIT"
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"autocannon": "^8.0.0"
|
|
44
|
+
}
|
|
29
45
|
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration Loading
|
|
3
|
+
*
|
|
4
|
+
* Loads config from (in order of precedence):
|
|
5
|
+
* 1. CLI arguments (highest)
|
|
6
|
+
* 2. Environment variables (JSS_*)
|
|
7
|
+
* 3. Config file (config.json)
|
|
8
|
+
* 4. Defaults (lowest)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'fs-extra';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Default configuration values
|
|
16
|
+
*/
|
|
17
|
+
export const defaults = {
|
|
18
|
+
// Server
|
|
19
|
+
port: 3000,
|
|
20
|
+
host: '0.0.0.0',
|
|
21
|
+
root: './data',
|
|
22
|
+
|
|
23
|
+
// SSL
|
|
24
|
+
sslKey: null,
|
|
25
|
+
sslCert: null,
|
|
26
|
+
|
|
27
|
+
// Features
|
|
28
|
+
multiuser: true,
|
|
29
|
+
conneg: false,
|
|
30
|
+
notifications: false,
|
|
31
|
+
|
|
32
|
+
// Logging
|
|
33
|
+
logger: true,
|
|
34
|
+
quiet: false,
|
|
35
|
+
|
|
36
|
+
// Paths
|
|
37
|
+
configPath: './.jss',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Map of environment variable names to config keys
|
|
42
|
+
*/
|
|
43
|
+
const envMap = {
|
|
44
|
+
JSS_PORT: 'port',
|
|
45
|
+
JSS_HOST: 'host',
|
|
46
|
+
JSS_ROOT: 'root',
|
|
47
|
+
JSS_SSL_KEY: 'sslKey',
|
|
48
|
+
JSS_SSL_CERT: 'sslCert',
|
|
49
|
+
JSS_MULTIUSER: 'multiuser',
|
|
50
|
+
JSS_CONNEG: 'conneg',
|
|
51
|
+
JSS_NOTIFICATIONS: 'notifications',
|
|
52
|
+
JSS_QUIET: 'quiet',
|
|
53
|
+
JSS_CONFIG_PATH: 'configPath',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Parse a value from environment variable string
|
|
58
|
+
*/
|
|
59
|
+
function parseEnvValue(value, key) {
|
|
60
|
+
if (value === undefined) return undefined;
|
|
61
|
+
|
|
62
|
+
// Boolean values
|
|
63
|
+
if (value.toLowerCase() === 'true') return true;
|
|
64
|
+
if (value.toLowerCase() === 'false') return false;
|
|
65
|
+
|
|
66
|
+
// Numeric values for known numeric keys
|
|
67
|
+
if (key === 'port' && !isNaN(value)) {
|
|
68
|
+
return parseInt(value, 10);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Load configuration from environment variables
|
|
76
|
+
*/
|
|
77
|
+
function loadEnvConfig() {
|
|
78
|
+
const config = {};
|
|
79
|
+
|
|
80
|
+
for (const [envVar, configKey] of Object.entries(envMap)) {
|
|
81
|
+
const value = process.env[envVar];
|
|
82
|
+
if (value !== undefined) {
|
|
83
|
+
config[configKey] = parseEnvValue(value, configKey);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return config;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Load configuration from a JSON file
|
|
92
|
+
*/
|
|
93
|
+
async function loadFileConfig(configFile) {
|
|
94
|
+
if (!configFile) return {};
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const fullPath = path.resolve(configFile);
|
|
98
|
+
if (await fs.pathExists(fullPath)) {
|
|
99
|
+
const content = await fs.readFile(fullPath, 'utf8');
|
|
100
|
+
return JSON.parse(content);
|
|
101
|
+
}
|
|
102
|
+
} catch (e) {
|
|
103
|
+
console.error(`Warning: Failed to load config file: ${e.message}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Merge configuration sources
|
|
111
|
+
* @param {object} cliOptions - Options from command line
|
|
112
|
+
* @param {string} configFile - Path to config file (optional)
|
|
113
|
+
* @returns {Promise<object>} Merged configuration
|
|
114
|
+
*/
|
|
115
|
+
export async function loadConfig(cliOptions = {}, configFile = null) {
|
|
116
|
+
// Load from file first
|
|
117
|
+
const fileConfig = await loadFileConfig(configFile || cliOptions.config);
|
|
118
|
+
|
|
119
|
+
// Load from environment
|
|
120
|
+
const envConfig = loadEnvConfig();
|
|
121
|
+
|
|
122
|
+
// Merge in order: defaults < file < env < cli
|
|
123
|
+
const config = {
|
|
124
|
+
...defaults,
|
|
125
|
+
...fileConfig,
|
|
126
|
+
...envConfig,
|
|
127
|
+
...filterUndefined(cliOptions),
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Derive additional settings
|
|
131
|
+
if (config.quiet) {
|
|
132
|
+
config.logger = false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Validate SSL config
|
|
136
|
+
if ((config.sslKey && !config.sslCert) || (!config.sslKey && config.sslCert)) {
|
|
137
|
+
throw new Error('Both --ssl-key and --ssl-cert must be provided together');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
config.ssl = !!(config.sslKey && config.sslCert);
|
|
141
|
+
|
|
142
|
+
return config;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Filter out undefined values from an object
|
|
147
|
+
*/
|
|
148
|
+
function filterUndefined(obj) {
|
|
149
|
+
const result = {};
|
|
150
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
151
|
+
if (value !== undefined) {
|
|
152
|
+
result[key] = value;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Save configuration to a file
|
|
160
|
+
*/
|
|
161
|
+
export async function saveConfig(config, configFile) {
|
|
162
|
+
const toSave = { ...config };
|
|
163
|
+
// Remove derived/runtime values
|
|
164
|
+
delete toSave.ssl;
|
|
165
|
+
delete toSave.logger;
|
|
166
|
+
|
|
167
|
+
await fs.ensureDir(path.dirname(configFile));
|
|
168
|
+
await fs.writeFile(configFile, JSON.stringify(toSave, null, 2));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Print configuration (for debugging)
|
|
173
|
+
*/
|
|
174
|
+
export function printConfig(config) {
|
|
175
|
+
console.log('\nConfiguration:');
|
|
176
|
+
console.log('─'.repeat(40));
|
|
177
|
+
console.log(` Port: ${config.port}`);
|
|
178
|
+
console.log(` Host: ${config.host}`);
|
|
179
|
+
console.log(` Root: ${path.resolve(config.root)}`);
|
|
180
|
+
console.log(` SSL: ${config.ssl ? 'enabled' : 'disabled'}`);
|
|
181
|
+
console.log(` Multi-user: ${config.multiuser}`);
|
|
182
|
+
console.log(` Conneg: ${config.conneg}`);
|
|
183
|
+
console.log(` Notifications: ${config.notifications}`);
|
|
184
|
+
console.log('─'.repeat(40));
|
|
185
|
+
}
|
package/src/handlers/resource.js
CHANGED
|
@@ -3,6 +3,7 @@ import { getAllHeaders } from '../ldp/headers.js';
|
|
|
3
3
|
import { generateContainerJsonLd, serializeJsonLd } from '../ldp/container.js';
|
|
4
4
|
import { isContainer, getContentType, isRdfContentType } from '../utils/url.js';
|
|
5
5
|
import { parseN3Patch, applyN3Patch, validatePatch } from '../patch/n3-patch.js';
|
|
6
|
+
import { parseSparqlUpdate, applySparqlUpdate } from '../patch/sparql-update.js';
|
|
6
7
|
import {
|
|
7
8
|
selectContentType,
|
|
8
9
|
canAcceptInput,
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
RDF_TYPES
|
|
13
14
|
} from '../rdf/conneg.js';
|
|
14
15
|
import { emitChange } from '../notifications/events.js';
|
|
16
|
+
import { checkIfMatch, checkIfNoneMatchForGet, checkIfNoneMatchForWrite } from '../utils/conditional.js';
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* Handle GET request
|
|
@@ -24,6 +26,15 @@ export async function handleGet(request, reply) {
|
|
|
24
26
|
return reply.code(404).send({ error: 'Not Found' });
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
// Check If-None-Match for conditional GET (304 Not Modified)
|
|
30
|
+
const ifNoneMatch = request.headers['if-none-match'];
|
|
31
|
+
if (ifNoneMatch) {
|
|
32
|
+
const check = checkIfNoneMatchForGet(ifNoneMatch, stats.etag);
|
|
33
|
+
if (!check.ok && check.notModified) {
|
|
34
|
+
return reply.code(304).send();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
27
38
|
const origin = request.headers.origin;
|
|
28
39
|
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
29
40
|
|
|
@@ -185,8 +196,28 @@ export async function handlePut(request, reply) {
|
|
|
185
196
|
});
|
|
186
197
|
}
|
|
187
198
|
|
|
188
|
-
// Check if resource already exists
|
|
189
|
-
const
|
|
199
|
+
// Check if resource already exists and get current ETag
|
|
200
|
+
const stats = await storage.stat(urlPath);
|
|
201
|
+
const existed = stats !== null;
|
|
202
|
+
const currentEtag = stats?.etag || null;
|
|
203
|
+
|
|
204
|
+
// Check If-Match header (for safe updates)
|
|
205
|
+
const ifMatch = request.headers['if-match'];
|
|
206
|
+
if (ifMatch) {
|
|
207
|
+
const check = checkIfMatch(ifMatch, currentEtag);
|
|
208
|
+
if (!check.ok) {
|
|
209
|
+
return reply.code(check.status).send({ error: check.error });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Check If-None-Match header (for create-only semantics)
|
|
214
|
+
const ifNoneMatch = request.headers['if-none-match'];
|
|
215
|
+
if (ifNoneMatch) {
|
|
216
|
+
const check = checkIfNoneMatchForWrite(ifNoneMatch, currentEtag);
|
|
217
|
+
if (!check.ok) {
|
|
218
|
+
return reply.code(check.status).send({ error: check.error });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
190
221
|
|
|
191
222
|
// Get content from request body
|
|
192
223
|
let content = request.body;
|
|
@@ -242,11 +273,21 @@ export async function handlePut(request, reply) {
|
|
|
242
273
|
export async function handleDelete(request, reply) {
|
|
243
274
|
const urlPath = request.url.split('?')[0];
|
|
244
275
|
|
|
245
|
-
|
|
246
|
-
|
|
276
|
+
// Check if resource exists and get current ETag
|
|
277
|
+
const stats = await storage.stat(urlPath);
|
|
278
|
+
if (!stats) {
|
|
247
279
|
return reply.code(404).send({ error: 'Not Found' });
|
|
248
280
|
}
|
|
249
281
|
|
|
282
|
+
// Check If-Match header (for safe deletes)
|
|
283
|
+
const ifMatch = request.headers['if-match'];
|
|
284
|
+
if (ifMatch) {
|
|
285
|
+
const check = checkIfMatch(ifMatch, stats.etag);
|
|
286
|
+
if (!check.ok) {
|
|
287
|
+
return reply.code(check.status).send({ error: check.error });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
250
291
|
const success = await storage.remove(urlPath);
|
|
251
292
|
if (!success) {
|
|
252
293
|
return reply.code(500).send({ error: 'Delete failed' });
|
|
@@ -274,10 +315,12 @@ export async function handleOptions(request, reply) {
|
|
|
274
315
|
|
|
275
316
|
const origin = request.headers.origin;
|
|
276
317
|
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
318
|
+
const connegEnabled = request.connegEnabled || false;
|
|
277
319
|
const headers = getAllHeaders({
|
|
278
320
|
isContainer: stats?.isDirectory || isContainer(urlPath),
|
|
279
321
|
origin,
|
|
280
|
-
resourceUrl
|
|
322
|
+
resourceUrl,
|
|
323
|
+
connegEnabled
|
|
281
324
|
});
|
|
282
325
|
|
|
283
326
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
@@ -286,7 +329,7 @@ export async function handleOptions(request, reply) {
|
|
|
286
329
|
|
|
287
330
|
/**
|
|
288
331
|
* Handle PATCH request
|
|
289
|
-
* Supports N3 Patch format (text/n3) for updating RDF resources
|
|
332
|
+
* Supports N3 Patch format (text/n3) and SPARQL Update for updating RDF resources
|
|
290
333
|
*/
|
|
291
334
|
export async function handlePatch(request, reply) {
|
|
292
335
|
const urlPath = request.url.split('?')[0];
|
|
@@ -298,14 +341,13 @@ export async function handlePatch(request, reply) {
|
|
|
298
341
|
|
|
299
342
|
// Check content type
|
|
300
343
|
const contentType = request.headers['content-type'] || '';
|
|
301
|
-
const isN3Patch = contentType.includes('text/n3') ||
|
|
302
|
-
|
|
303
|
-
contentType.includes('application/sparql-update');
|
|
344
|
+
const isN3Patch = contentType.includes('text/n3') || contentType.includes('application/n3');
|
|
345
|
+
const isSparqlUpdate = contentType.includes('application/sparql-update');
|
|
304
346
|
|
|
305
|
-
if (!isN3Patch) {
|
|
347
|
+
if (!isN3Patch && !isSparqlUpdate) {
|
|
306
348
|
return reply.code(415).send({
|
|
307
349
|
error: 'Unsupported Media Type',
|
|
308
|
-
message: 'PATCH requires Content-Type: text/n3
|
|
350
|
+
message: 'PATCH requires Content-Type: text/n3 (N3 Patch) or application/sparql-update (SPARQL Update)'
|
|
309
351
|
});
|
|
310
352
|
}
|
|
311
353
|
|
|
@@ -315,6 +357,15 @@ export async function handlePatch(request, reply) {
|
|
|
315
357
|
return reply.code(404).send({ error: 'Not Found' });
|
|
316
358
|
}
|
|
317
359
|
|
|
360
|
+
// Check If-Match header (for safe updates)
|
|
361
|
+
const ifMatch = request.headers['if-match'];
|
|
362
|
+
if (ifMatch) {
|
|
363
|
+
const check = checkIfMatch(ifMatch, stats.etag);
|
|
364
|
+
if (!check.ok) {
|
|
365
|
+
return reply.code(check.status).send({ error: check.error });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
318
369
|
// Read existing content
|
|
319
370
|
const existingContent = await storage.read(urlPath);
|
|
320
371
|
if (existingContent === null) {
|
|
@@ -338,31 +389,49 @@ export async function handlePatch(request, reply) {
|
|
|
338
389
|
: request.body;
|
|
339
390
|
|
|
340
391
|
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
341
|
-
let patch;
|
|
342
|
-
try {
|
|
343
|
-
patch = parseN3Patch(patchContent, resourceUrl);
|
|
344
|
-
} catch (e) {
|
|
345
|
-
return reply.code(400).send({
|
|
346
|
-
error: 'Bad Request',
|
|
347
|
-
message: 'Invalid N3 Patch format: ' + e.message
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
392
|
|
|
351
|
-
// Validate that deletes exist (optional strict mode)
|
|
352
|
-
// const validation = validatePatch(document, patch, resourceUrl);
|
|
353
|
-
// if (!validation.valid) {
|
|
354
|
-
// return reply.code(409).send({ error: 'Conflict', message: validation.error });
|
|
355
|
-
// }
|
|
356
|
-
|
|
357
|
-
// Apply the patch
|
|
358
393
|
let updatedDocument;
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
})
|
|
394
|
+
|
|
395
|
+
if (isSparqlUpdate) {
|
|
396
|
+
// Handle SPARQL Update
|
|
397
|
+
let update;
|
|
398
|
+
try {
|
|
399
|
+
update = parseSparqlUpdate(patchContent, resourceUrl);
|
|
400
|
+
} catch (e) {
|
|
401
|
+
return reply.code(400).send({
|
|
402
|
+
error: 'Bad Request',
|
|
403
|
+
message: 'Invalid SPARQL Update: ' + e.message
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
updatedDocument = applySparqlUpdate(document, update, resourceUrl);
|
|
409
|
+
} catch (e) {
|
|
410
|
+
return reply.code(409).send({
|
|
411
|
+
error: 'Conflict',
|
|
412
|
+
message: 'Failed to apply SPARQL Update: ' + e.message
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
} else {
|
|
416
|
+
// Handle N3 Patch
|
|
417
|
+
let patch;
|
|
418
|
+
try {
|
|
419
|
+
patch = parseN3Patch(patchContent, resourceUrl);
|
|
420
|
+
} catch (e) {
|
|
421
|
+
return reply.code(400).send({
|
|
422
|
+
error: 'Bad Request',
|
|
423
|
+
message: 'Invalid N3 Patch format: ' + e.message
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
try {
|
|
428
|
+
updatedDocument = applyN3Patch(document, patch, resourceUrl);
|
|
429
|
+
} catch (e) {
|
|
430
|
+
return reply.code(409).send({
|
|
431
|
+
error: 'Conflict',
|
|
432
|
+
message: 'Failed to apply patch: ' + e.message
|
|
433
|
+
});
|
|
434
|
+
}
|
|
366
435
|
}
|
|
367
436
|
|
|
368
437
|
// Write updated document
|