javascript-solid-server 0.0.8 → 0.0.10

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.
@@ -0,0 +1,401 @@
1
+ /**
2
+ * SPARQL Update Parser
3
+ *
4
+ * Parses SPARQL Update syntax and applies changes to JSON-LD documents.
5
+ * Supports:
6
+ * - INSERT DATA { triples }
7
+ * - DELETE DATA { triples }
8
+ * - DELETE { pattern } INSERT { pattern } WHERE { pattern }
9
+ *
10
+ * Note: This is a simplified parser for common Solid use cases.
11
+ */
12
+
13
+ import { Parser, Writer, DataFactory } from 'n3';
14
+ const { namedNode, literal, blankNode, quad } = DataFactory;
15
+
16
+ /**
17
+ * Parse a SPARQL Update query
18
+ * @param {string} sparql - The SPARQL Update query
19
+ * @param {string} baseUri - Base URI for relative references
20
+ * @returns {{ inserts: Array, deletes: Array }}
21
+ */
22
+ export function parseSparqlUpdate(sparql, baseUri) {
23
+ const result = {
24
+ inserts: [],
25
+ deletes: []
26
+ };
27
+
28
+ // Normalize whitespace
29
+ const normalized = sparql.trim();
30
+
31
+ // Extract prefixes
32
+ const prefixes = {};
33
+ const prefixRegex = /PREFIX\s+(\w*):?\s*<([^>]+)>/gi;
34
+ let match;
35
+ while ((match = prefixRegex.exec(normalized)) !== null) {
36
+ prefixes[match[1] || ''] = match[2];
37
+ }
38
+
39
+ // Remove prefix declarations for parsing
40
+ let query = normalized.replace(/PREFIX\s+\w*:?\s*<[^>]+>\s*/gi, '').trim();
41
+
42
+ // Handle INSERT DATA
43
+ const insertDataMatch = query.match(/INSERT\s+DATA\s*\{([^}]+)\}/is);
44
+ if (insertDataMatch) {
45
+ const triples = parseTriples(insertDataMatch[1], baseUri, prefixes);
46
+ result.inserts.push(...triples);
47
+ }
48
+
49
+ // Handle DELETE DATA
50
+ const deleteDataMatch = query.match(/DELETE\s+DATA\s*\{([^}]+)\}/is);
51
+ if (deleteDataMatch) {
52
+ const triples = parseTriples(deleteDataMatch[1], baseUri, prefixes);
53
+ result.deletes.push(...triples);
54
+ }
55
+
56
+ // Handle DELETE { } INSERT { } WHERE { }
57
+ // This is a simplified version - we treat DELETE/INSERT as data operations
58
+ const deleteInsertMatch = query.match(/DELETE\s*\{([^}]*)\}\s*INSERT\s*\{([^}]*)\}\s*WHERE\s*\{[^}]*\}/is);
59
+ if (deleteInsertMatch) {
60
+ if (deleteInsertMatch[1].trim()) {
61
+ const deleteTriples = parseTriples(deleteInsertMatch[1], baseUri, prefixes);
62
+ result.deletes.push(...deleteTriples);
63
+ }
64
+ if (deleteInsertMatch[2].trim()) {
65
+ const insertTriples = parseTriples(deleteInsertMatch[2], baseUri, prefixes);
66
+ result.inserts.push(...insertTriples);
67
+ }
68
+ }
69
+
70
+ // Handle DELETE WHERE { } (shorthand for DELETE { pattern } WHERE { pattern })
71
+ const deleteWhereMatch = query.match(/DELETE\s+WHERE\s*\{([^}]+)\}/is);
72
+ if (deleteWhereMatch) {
73
+ const triples = parseTriples(deleteWhereMatch[1], baseUri, prefixes);
74
+ result.deletes.push(...triples);
75
+ }
76
+
77
+ // Handle standalone INSERT { } WHERE { }
78
+ const insertWhereMatch = query.match(/INSERT\s*\{([^}]+)\}\s*WHERE\s*\{[^}]*\}/is);
79
+ if (insertWhereMatch && !deleteInsertMatch) {
80
+ const triples = parseTriples(insertWhereMatch[1], baseUri, prefixes);
81
+ result.inserts.push(...triples);
82
+ }
83
+
84
+ return result;
85
+ }
86
+
87
+ /**
88
+ * Parse Turtle-like triples into an array of triple objects
89
+ * @param {string} triplesStr - Turtle-formatted triples
90
+ * @param {string} baseUri - Base URI
91
+ * @param {object} prefixes - Prefix mappings
92
+ * @returns {Array<{subject: string, predicate: string, object: any}>}
93
+ */
94
+ function parseTriples(triplesStr, baseUri, prefixes = {}) {
95
+ const triples = [];
96
+
97
+ // Build prefix declarations for N3 parser
98
+ let turtleDoc = '';
99
+ for (const [prefix, uri] of Object.entries(prefixes)) {
100
+ turtleDoc += `@prefix ${prefix}: <${uri}> .\n`;
101
+ }
102
+ turtleDoc += `@base <${baseUri}> .\n`;
103
+ turtleDoc += triplesStr;
104
+
105
+ try {
106
+ const parser = new Parser({ baseIRI: baseUri });
107
+ const quads = parser.parse(turtleDoc);
108
+
109
+ for (const q of quads) {
110
+ triples.push({
111
+ subject: termToValue(q.subject, baseUri),
112
+ predicate: termToValue(q.predicate, baseUri),
113
+ object: termToJsonLdValue(q.object, baseUri)
114
+ });
115
+ }
116
+ } catch (e) {
117
+ // If N3 parsing fails, try simple regex parsing for basic patterns
118
+ const simpleTriples = parseSimpleTriples(triplesStr, baseUri, prefixes);
119
+ triples.push(...simpleTriples);
120
+ }
121
+
122
+ return triples;
123
+ }
124
+
125
+ /**
126
+ * Convert N3 term to a string value
127
+ */
128
+ function termToValue(term, baseUri) {
129
+ if (term.termType === 'NamedNode') {
130
+ return term.value;
131
+ } else if (term.termType === 'BlankNode') {
132
+ return `_:${term.value}`;
133
+ }
134
+ return term.value;
135
+ }
136
+
137
+ /**
138
+ * Convert N3 term to JSON-LD value format
139
+ */
140
+ function termToJsonLdValue(term, baseUri) {
141
+ if (term.termType === 'NamedNode') {
142
+ return { '@id': term.value };
143
+ } else if (term.termType === 'BlankNode') {
144
+ return { '@id': `_:${term.value}` };
145
+ } else if (term.termType === 'Literal') {
146
+ if (term.datatype && term.datatype.value !== 'http://www.w3.org/2001/XMLSchema#string') {
147
+ return {
148
+ '@value': term.value,
149
+ '@type': term.datatype.value
150
+ };
151
+ } else if (term.language) {
152
+ return {
153
+ '@value': term.value,
154
+ '@language': term.language
155
+ };
156
+ }
157
+ return term.value;
158
+ }
159
+ return term.value;
160
+ }
161
+
162
+ /**
163
+ * Simple regex-based triple parsing as fallback
164
+ */
165
+ function parseSimpleTriples(triplesStr, baseUri, prefixes) {
166
+ const triples = [];
167
+
168
+ // Very basic pattern matching for simple cases
169
+ // Format: <subject> <predicate> <object> .
170
+ // or: <subject> <predicate> "literal" .
171
+ const lines = triplesStr.split(/\.\s*/).filter(l => l.trim());
172
+
173
+ for (const line of lines) {
174
+ const parts = line.trim().match(/^(<[^>]+>|[\w:]+)\s+(<[^>]+>|[\w:]+)\s+(.+)$/);
175
+ if (parts) {
176
+ const subject = expandUri(parts[1].trim(), baseUri, prefixes);
177
+ const predicate = expandUri(parts[2].trim(), baseUri, prefixes);
178
+ const objectStr = parts[3].trim();
179
+
180
+ let object;
181
+ if (objectStr.startsWith('<') && objectStr.endsWith('>')) {
182
+ object = { '@id': expandUri(objectStr, baseUri, prefixes) };
183
+ } else if (objectStr.startsWith('"')) {
184
+ // Parse literal
185
+ const literalMatch = objectStr.match(/^"([^"]*)"(?:@(\w+)|\^\^<([^>]+)>)?/);
186
+ if (literalMatch) {
187
+ if (literalMatch[2]) {
188
+ object = { '@value': literalMatch[1], '@language': literalMatch[2] };
189
+ } else if (literalMatch[3]) {
190
+ object = { '@value': literalMatch[1], '@type': literalMatch[3] };
191
+ } else {
192
+ object = literalMatch[1];
193
+ }
194
+ } else {
195
+ object = objectStr.replace(/^"|"$/g, '');
196
+ }
197
+ } else {
198
+ // Prefixed name
199
+ object = { '@id': expandUri(objectStr, baseUri, prefixes) };
200
+ }
201
+
202
+ triples.push({ subject, predicate, object });
203
+ }
204
+ }
205
+
206
+ return triples;
207
+ }
208
+
209
+ /**
210
+ * Expand a prefixed name or relative URI
211
+ */
212
+ function expandUri(uri, baseUri, prefixes) {
213
+ if (uri.startsWith('<') && uri.endsWith('>')) {
214
+ const inner = uri.slice(1, -1);
215
+ if (inner.startsWith('http://') || inner.startsWith('https://')) {
216
+ return inner;
217
+ }
218
+ // Relative URI
219
+ return new URL(inner, baseUri).href;
220
+ }
221
+
222
+ // Check for prefixed name
223
+ const colonIndex = uri.indexOf(':');
224
+ if (colonIndex > 0) {
225
+ const prefix = uri.substring(0, colonIndex);
226
+ const local = uri.substring(colonIndex + 1);
227
+ if (prefixes[prefix]) {
228
+ return prefixes[prefix] + local;
229
+ }
230
+ }
231
+
232
+ return uri;
233
+ }
234
+
235
+ /**
236
+ * Apply SPARQL Update to a JSON-LD document
237
+ * @param {object} document - The JSON-LD document
238
+ * @param {{ inserts: Array, deletes: Array }} update - Parsed SPARQL Update
239
+ * @param {string} baseUri - Base URI of the document
240
+ * @returns {object} Updated document
241
+ */
242
+ export function applySparqlUpdate(document, update, baseUri) {
243
+ // Clone the document
244
+ let doc = JSON.parse(JSON.stringify(document));
245
+
246
+ // Ensure document is in a workable format
247
+ if (!Array.isArray(doc)) {
248
+ doc = [doc];
249
+ }
250
+
251
+ // Apply deletes
252
+ for (const triple of update.deletes) {
253
+ doc = deleteTriple(doc, triple, baseUri);
254
+ }
255
+
256
+ // Apply inserts
257
+ for (const triple of update.inserts) {
258
+ doc = insertTriple(doc, triple, baseUri);
259
+ }
260
+
261
+ // Return single object if only one node
262
+ if (Array.isArray(doc) && doc.length === 1) {
263
+ return doc[0];
264
+ }
265
+
266
+ return doc;
267
+ }
268
+
269
+ /**
270
+ * Delete a triple from a JSON-LD document
271
+ */
272
+ function deleteTriple(doc, triple, baseUri) {
273
+ const subjectId = resolveSubjectId(triple.subject, baseUri);
274
+
275
+ for (const node of doc) {
276
+ const nodeId = node['@id'] || '';
277
+ if (matchesSubject(nodeId, subjectId, baseUri)) {
278
+ // Found the subject node, remove the predicate-object pair
279
+ if (node[triple.predicate]) {
280
+ const values = Array.isArray(node[triple.predicate])
281
+ ? node[triple.predicate]
282
+ : [node[triple.predicate]];
283
+
284
+ const filtered = values.filter(v => !valuesMatch(v, triple.object));
285
+
286
+ if (filtered.length === 0) {
287
+ delete node[triple.predicate];
288
+ } else if (filtered.length === 1) {
289
+ node[triple.predicate] = filtered[0];
290
+ } else {
291
+ node[triple.predicate] = filtered;
292
+ }
293
+ }
294
+ }
295
+ }
296
+
297
+ return doc;
298
+ }
299
+
300
+ /**
301
+ * Insert a triple into a JSON-LD document
302
+ */
303
+ function insertTriple(doc, triple, baseUri) {
304
+ const subjectId = resolveSubjectId(triple.subject, baseUri);
305
+
306
+ // Find existing node for subject
307
+ let targetNode = null;
308
+ for (const node of doc) {
309
+ const nodeId = node['@id'] || '';
310
+ if (matchesSubject(nodeId, subjectId, baseUri)) {
311
+ targetNode = node;
312
+ break;
313
+ }
314
+ }
315
+
316
+ // Create new node if not found
317
+ if (!targetNode) {
318
+ targetNode = { '@id': subjectId };
319
+ doc.push(targetNode);
320
+ }
321
+
322
+ // Add the predicate-object pair
323
+ if (targetNode[triple.predicate]) {
324
+ const existing = targetNode[triple.predicate];
325
+ if (Array.isArray(existing)) {
326
+ if (!existing.some(v => valuesMatch(v, triple.object))) {
327
+ existing.push(triple.object);
328
+ }
329
+ } else {
330
+ if (!valuesMatch(existing, triple.object)) {
331
+ targetNode[triple.predicate] = [existing, triple.object];
332
+ }
333
+ }
334
+ } else {
335
+ targetNode[triple.predicate] = triple.object;
336
+ }
337
+
338
+ return doc;
339
+ }
340
+
341
+ /**
342
+ * Resolve subject ID, handling relative URIs and hash URIs
343
+ */
344
+ function resolveSubjectId(subject, baseUri) {
345
+ if (subject.startsWith('#')) {
346
+ return baseUri + subject;
347
+ }
348
+ if (subject.startsWith('_:')) {
349
+ return subject;
350
+ }
351
+ if (!subject.includes('://')) {
352
+ return new URL(subject, baseUri).href;
353
+ }
354
+ return subject;
355
+ }
356
+
357
+ /**
358
+ * Check if a node ID matches a subject
359
+ */
360
+ function matchesSubject(nodeId, subjectId, baseUri) {
361
+ if (nodeId === subjectId) return true;
362
+
363
+ // Handle hash URIs
364
+ if (subjectId.startsWith('#') && nodeId === baseUri + subjectId) return true;
365
+ if (nodeId.startsWith('#') && subjectId === baseUri + nodeId) return true;
366
+
367
+ return false;
368
+ }
369
+
370
+ /**
371
+ * Check if two JSON-LD values match
372
+ */
373
+ function valuesMatch(a, b) {
374
+ if (typeof a === 'string' && typeof b === 'string') {
375
+ return a === b;
376
+ }
377
+
378
+ if (typeof a === 'object' && typeof b === 'object') {
379
+ // Compare @id
380
+ if (a['@id'] && b['@id']) {
381
+ return a['@id'] === b['@id'];
382
+ }
383
+
384
+ // Compare @value
385
+ if (a['@value'] !== undefined && b['@value'] !== undefined) {
386
+ return a['@value'] === b['@value'] &&
387
+ a['@type'] === b['@type'] &&
388
+ a['@language'] === b['@language'];
389
+ }
390
+ }
391
+
392
+ // Mixed string/object comparison
393
+ if (typeof a === 'string' && typeof b === 'object' && b['@value']) {
394
+ return a === b['@value'];
395
+ }
396
+ if (typeof b === 'string' && typeof a === 'object' && a['@value']) {
397
+ return b === a['@value'];
398
+ }
399
+
400
+ return JSON.stringify(a) === JSON.stringify(b);
401
+ }
package/src/server.js CHANGED
@@ -3,16 +3,20 @@ import { handleGet, handleHead, handlePut, handleDelete, handleOptions, handlePa
3
3
  import { handlePost, handleCreatePod } from './handlers/container.js';
4
4
  import { getCorsHeaders } from './ldp/headers.js';
5
5
  import { authorize, handleUnauthorized } from './auth/middleware.js';
6
+ import { notificationsPlugin } from './notifications/index.js';
6
7
 
7
8
  /**
8
9
  * Create and configure Fastify server
9
10
  * @param {object} options - Server options
10
11
  * @param {boolean} options.logger - Enable logging (default true)
11
12
  * @param {boolean} options.conneg - Enable content negotiation for RDF (default false)
13
+ * @param {boolean} options.notifications - Enable WebSocket notifications (default false)
12
14
  */
13
15
  export function createServer(options = {}) {
14
16
  // Content negotiation is OFF by default - we're a JSON-LD native server
15
17
  const connegEnabled = options.conneg ?? false;
18
+ // WebSocket notifications are OFF by default
19
+ const notificationsEnabled = options.notifications ?? false;
16
20
 
17
21
  const fastify = Fastify({
18
22
  logger: options.logger ?? true,
@@ -28,16 +32,29 @@ export function createServer(options = {}) {
28
32
 
29
33
  // Attach server config to requests
30
34
  fastify.decorateRequest('connegEnabled', null);
35
+ fastify.decorateRequest('notificationsEnabled', null);
31
36
  fastify.addHook('onRequest', async (request) => {
32
37
  request.connegEnabled = connegEnabled;
38
+ request.notificationsEnabled = notificationsEnabled;
33
39
  });
34
40
 
41
+ // Register WebSocket notifications plugin if enabled
42
+ if (notificationsEnabled) {
43
+ fastify.register(notificationsPlugin);
44
+ }
45
+
35
46
  // Global CORS preflight
36
47
  fastify.addHook('onRequest', async (request, reply) => {
37
48
  // Add CORS headers to all responses
38
49
  const corsHeaders = getCorsHeaders(request.headers.origin);
39
50
  Object.entries(corsHeaders).forEach(([k, v]) => reply.header(k, v));
40
51
 
52
+ // Add Updates-Via header for WebSocket notification discovery
53
+ if (notificationsEnabled) {
54
+ const wsProtocol = request.protocol === 'https' ? 'wss' : 'ws';
55
+ reply.header('Updates-Via', `${wsProtocol}://${request.hostname}/.notifications`);
56
+ }
57
+
41
58
  // Handle preflight OPTIONS
42
59
  if (request.method === 'OPTIONS') {
43
60
  // Add Allow header for LDP compliance
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Conditional Request Utilities
3
+ *
4
+ * Implements HTTP conditional request headers:
5
+ * - If-Match: Proceed only if ETag matches (for safe updates)
6
+ * - If-None-Match: Proceed only if ETag doesn't match (for caching/create-only)
7
+ */
8
+
9
+ /**
10
+ * Normalize an ETag value (remove weak prefix and quotes)
11
+ * @param {string} etag
12
+ * @returns {string}
13
+ */
14
+ function normalizeEtag(etag) {
15
+ if (!etag) return '';
16
+ // Remove weak prefix W/
17
+ let normalized = etag.replace(/^W\//, '');
18
+ // Remove surrounding quotes
19
+ normalized = normalized.replace(/^"(.*)"$/, '$1');
20
+ return normalized;
21
+ }
22
+
23
+ /**
24
+ * Parse an If-Match or If-None-Match header value
25
+ * @param {string} headerValue
26
+ * @returns {string[]} Array of ETags, or ['*'] for wildcard
27
+ */
28
+ function parseEtagHeader(headerValue) {
29
+ if (!headerValue) return [];
30
+ if (headerValue.trim() === '*') return ['*'];
31
+
32
+ // Split by comma and normalize each ETag
33
+ return headerValue.split(',').map(etag => normalizeEtag(etag.trim()));
34
+ }
35
+
36
+ /**
37
+ * Check If-Match header
38
+ * Returns true if the request should proceed, false if it should be rejected (412)
39
+ *
40
+ * @param {string} ifMatchHeader - The If-Match header value
41
+ * @param {string|null} currentEtag - Current ETag of the resource (null if doesn't exist)
42
+ * @returns {{ ok: boolean, status?: number, error?: string }}
43
+ */
44
+ export function checkIfMatch(ifMatchHeader, currentEtag) {
45
+ if (!ifMatchHeader) {
46
+ return { ok: true }; // No If-Match header, proceed
47
+ }
48
+
49
+ const etags = parseEtagHeader(ifMatchHeader);
50
+
51
+ // If resource doesn't exist, If-Match always fails
52
+ if (currentEtag === null) {
53
+ return {
54
+ ok: false,
55
+ status: 412,
56
+ error: 'Precondition Failed: Resource does not exist'
57
+ };
58
+ }
59
+
60
+ // Wildcard matches any existing resource
61
+ if (etags.includes('*')) {
62
+ return { ok: true };
63
+ }
64
+
65
+ // Check if any ETag matches
66
+ const normalizedCurrent = normalizeEtag(currentEtag);
67
+ const matches = etags.some(etag => etag === normalizedCurrent);
68
+
69
+ if (!matches) {
70
+ return {
71
+ ok: false,
72
+ status: 412,
73
+ error: 'Precondition Failed: ETag mismatch'
74
+ };
75
+ }
76
+
77
+ return { ok: true };
78
+ }
79
+
80
+ /**
81
+ * Check If-None-Match header for GET/HEAD (caching)
82
+ * Returns true if the request should proceed, false if 304 Not Modified
83
+ *
84
+ * @param {string} ifNoneMatchHeader - The If-None-Match header value
85
+ * @param {string|null} currentEtag - Current ETag of the resource
86
+ * @returns {{ ok: boolean, notModified?: boolean }}
87
+ */
88
+ export function checkIfNoneMatchForGet(ifNoneMatchHeader, currentEtag) {
89
+ if (!ifNoneMatchHeader || currentEtag === null) {
90
+ return { ok: true }; // No header or no resource, proceed
91
+ }
92
+
93
+ const etags = parseEtagHeader(ifNoneMatchHeader);
94
+
95
+ // Wildcard matches any existing resource
96
+ if (etags.includes('*')) {
97
+ return { ok: false, notModified: true };
98
+ }
99
+
100
+ // Check if any ETag matches
101
+ const normalizedCurrent = normalizeEtag(currentEtag);
102
+ const matches = etags.some(etag => etag === normalizedCurrent);
103
+
104
+ if (matches) {
105
+ return { ok: false, notModified: true };
106
+ }
107
+
108
+ return { ok: true };
109
+ }
110
+
111
+ /**
112
+ * Check If-None-Match header for PUT/POST (create-only semantics)
113
+ * Returns true if the request should proceed, false if 412 Precondition Failed
114
+ *
115
+ * @param {string} ifNoneMatchHeader - The If-None-Match header value
116
+ * @param {string|null} currentEtag - Current ETag of the resource (null if doesn't exist)
117
+ * @returns {{ ok: boolean, status?: number, error?: string }}
118
+ */
119
+ export function checkIfNoneMatchForWrite(ifNoneMatchHeader, currentEtag) {
120
+ if (!ifNoneMatchHeader) {
121
+ return { ok: true }; // No header, proceed
122
+ }
123
+
124
+ const etags = parseEtagHeader(ifNoneMatchHeader);
125
+
126
+ // If-None-Match: * means "only if resource doesn't exist"
127
+ if (etags.includes('*')) {
128
+ if (currentEtag !== null) {
129
+ return {
130
+ ok: false,
131
+ status: 412,
132
+ error: 'Precondition Failed: Resource already exists'
133
+ };
134
+ }
135
+ return { ok: true };
136
+ }
137
+
138
+ // Check if any ETag matches (if so, fail)
139
+ if (currentEtag !== null) {
140
+ const normalizedCurrent = normalizeEtag(currentEtag);
141
+ const matches = etags.some(etag => etag === normalizedCurrent);
142
+
143
+ if (matches) {
144
+ return {
145
+ ok: false,
146
+ status: 412,
147
+ error: 'Precondition Failed: ETag matches'
148
+ };
149
+ }
150
+ }
151
+
152
+ return { ok: true };
153
+ }