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.
- package/.claude/settings.local.json +3 -1
- package/README.md +95 -6
- package/benchmark.js +145 -249
- package/package.json +16 -3
- package/src/handlers/container.js +7 -0
- package/src/handlers/resource.js +117 -33
- package/src/ldp/headers.js +9 -4
- package/src/notifications/events.js +22 -0
- package/src/notifications/index.js +49 -0
- package/src/notifications/websocket.js +183 -0
- package/src/patch/sparql-update.js +401 -0
- package/src/server.js +17 -0
- package/src/utils/conditional.js +153 -0
- package/test/conditional.test.js +250 -0
- package/test/notifications.test.js +348 -0
- package/test/sparql-update.test.js +219 -0
|
@@ -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
|
+
}
|