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.
@@ -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,
@@ -11,6 +12,8 @@ import {
11
12
  getVaryHeader,
12
13
  RDF_TYPES
13
14
  } from '../rdf/conneg.js';
15
+ import { emitChange } from '../notifications/events.js';
16
+ import { checkIfMatch, checkIfNoneMatchForGet, checkIfNoneMatchForWrite } from '../utils/conditional.js';
14
17
 
15
18
  /**
16
19
  * Handle GET request
@@ -23,6 +26,15 @@ export async function handleGet(request, reply) {
23
26
  return reply.code(404).send({ error: 'Not Found' });
24
27
  }
25
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
+
26
38
  const origin = request.headers.origin;
27
39
  const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
28
40
 
@@ -184,8 +196,28 @@ export async function handlePut(request, reply) {
184
196
  });
185
197
  }
186
198
 
187
- // Check if resource already exists
188
- const existed = await storage.exists(urlPath);
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
+ }
189
221
 
190
222
  // Get content from request body
191
223
  let content = request.body;
@@ -226,6 +258,12 @@ export async function handlePut(request, reply) {
226
258
  headers['Vary'] = getVaryHeader(connegEnabled);
227
259
 
228
260
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
261
+
262
+ // Emit change notification for WebSocket subscribers
263
+ if (request.notificationsEnabled) {
264
+ emitChange(resourceUrl);
265
+ }
266
+
229
267
  return reply.code(existed ? 204 : 201).send();
230
268
  }
231
269
 
@@ -235,11 +273,21 @@ export async function handlePut(request, reply) {
235
273
  export async function handleDelete(request, reply) {
236
274
  const urlPath = request.url.split('?')[0];
237
275
 
238
- const existed = await storage.exists(urlPath);
239
- if (!existed) {
276
+ // Check if resource exists and get current ETag
277
+ const stats = await storage.stat(urlPath);
278
+ if (!stats) {
240
279
  return reply.code(404).send({ error: 'Not Found' });
241
280
  }
242
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
+
243
291
  const success = await storage.remove(urlPath);
244
292
  if (!success) {
245
293
  return reply.code(500).send({ error: 'Delete failed' });
@@ -250,6 +298,11 @@ export async function handleDelete(request, reply) {
250
298
  const headers = getAllHeaders({ isContainer: false, origin, resourceUrl });
251
299
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
252
300
 
301
+ // Emit change notification for WebSocket subscribers
302
+ if (request.notificationsEnabled) {
303
+ emitChange(resourceUrl);
304
+ }
305
+
253
306
  return reply.code(204).send();
254
307
  }
255
308
 
@@ -274,7 +327,7 @@ export async function handleOptions(request, reply) {
274
327
 
275
328
  /**
276
329
  * Handle PATCH request
277
- * Supports N3 Patch format (text/n3) for updating RDF resources
330
+ * Supports N3 Patch format (text/n3) and SPARQL Update for updating RDF resources
278
331
  */
279
332
  export async function handlePatch(request, reply) {
280
333
  const urlPath = request.url.split('?')[0];
@@ -286,14 +339,13 @@ export async function handlePatch(request, reply) {
286
339
 
287
340
  // Check content type
288
341
  const contentType = request.headers['content-type'] || '';
289
- const isN3Patch = contentType.includes('text/n3') ||
290
- contentType.includes('application/n3') ||
291
- contentType.includes('application/sparql-update');
342
+ const isN3Patch = contentType.includes('text/n3') || contentType.includes('application/n3');
343
+ const isSparqlUpdate = contentType.includes('application/sparql-update');
292
344
 
293
- if (!isN3Patch) {
345
+ if (!isN3Patch && !isSparqlUpdate) {
294
346
  return reply.code(415).send({
295
347
  error: 'Unsupported Media Type',
296
- message: 'PATCH requires Content-Type: text/n3 for N3 Patch format'
348
+ message: 'PATCH requires Content-Type: text/n3 (N3 Patch) or application/sparql-update (SPARQL Update)'
297
349
  });
298
350
  }
299
351
 
@@ -303,6 +355,15 @@ export async function handlePatch(request, reply) {
303
355
  return reply.code(404).send({ error: 'Not Found' });
304
356
  }
305
357
 
358
+ // Check If-Match header (for safe updates)
359
+ const ifMatch = request.headers['if-match'];
360
+ if (ifMatch) {
361
+ const check = checkIfMatch(ifMatch, stats.etag);
362
+ if (!check.ok) {
363
+ return reply.code(check.status).send({ error: check.error });
364
+ }
365
+ }
366
+
306
367
  // Read existing content
307
368
  const existingContent = await storage.read(urlPath);
308
369
  if (existingContent === null) {
@@ -326,31 +387,49 @@ export async function handlePatch(request, reply) {
326
387
  : request.body;
327
388
 
328
389
  const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
329
- let patch;
330
- try {
331
- patch = parseN3Patch(patchContent, resourceUrl);
332
- } catch (e) {
333
- return reply.code(400).send({
334
- error: 'Bad Request',
335
- message: 'Invalid N3 Patch format: ' + e.message
336
- });
337
- }
338
390
 
339
- // Validate that deletes exist (optional strict mode)
340
- // const validation = validatePatch(document, patch, resourceUrl);
341
- // if (!validation.valid) {
342
- // return reply.code(409).send({ error: 'Conflict', message: validation.error });
343
- // }
344
-
345
- // Apply the patch
346
391
  let updatedDocument;
347
- try {
348
- updatedDocument = applyN3Patch(document, patch, resourceUrl);
349
- } catch (e) {
350
- return reply.code(409).send({
351
- error: 'Conflict',
352
- message: 'Failed to apply patch: ' + e.message
353
- });
392
+
393
+ if (isSparqlUpdate) {
394
+ // Handle SPARQL Update
395
+ let update;
396
+ try {
397
+ update = parseSparqlUpdate(patchContent, resourceUrl);
398
+ } catch (e) {
399
+ return reply.code(400).send({
400
+ error: 'Bad Request',
401
+ message: 'Invalid SPARQL Update: ' + e.message
402
+ });
403
+ }
404
+
405
+ try {
406
+ updatedDocument = applySparqlUpdate(document, update, resourceUrl);
407
+ } catch (e) {
408
+ return reply.code(409).send({
409
+ error: 'Conflict',
410
+ message: 'Failed to apply SPARQL Update: ' + e.message
411
+ });
412
+ }
413
+ } else {
414
+ // Handle N3 Patch
415
+ let patch;
416
+ try {
417
+ patch = parseN3Patch(patchContent, resourceUrl);
418
+ } catch (e) {
419
+ return reply.code(400).send({
420
+ error: 'Bad Request',
421
+ message: 'Invalid N3 Patch format: ' + e.message
422
+ });
423
+ }
424
+
425
+ try {
426
+ updatedDocument = applyN3Patch(document, patch, resourceUrl);
427
+ } catch (e) {
428
+ return reply.code(409).send({
429
+ error: 'Conflict',
430
+ message: 'Failed to apply patch: ' + e.message
431
+ });
432
+ }
354
433
  }
355
434
 
356
435
  // Write updated document
@@ -365,5 +444,10 @@ export async function handlePatch(request, reply) {
365
444
  const headers = getAllHeaders({ isContainer: false, origin, resourceUrl });
366
445
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
367
446
 
447
+ // Emit change notification for WebSocket subscribers
448
+ if (request.notificationsEnabled) {
449
+ emitChange(resourceUrl);
450
+ }
451
+
368
452
  return reply.code(204).send();
369
453
  }
@@ -49,7 +49,7 @@ export function getAclUrl(resourceUrl, isContainer) {
49
49
  * @param {object} options
50
50
  * @returns {object}
51
51
  */
52
- export function getResponseHeaders({ isContainer = false, etag = null, contentType = null, resourceUrl = null, wacAllow = null, connegEnabled = false }) {
52
+ export function getResponseHeaders({ isContainer = false, etag = null, contentType = null, resourceUrl = null, wacAllow = null, connegEnabled = false, updatesVia = null }) {
53
53
  // Calculate ACL URL if resource URL provided
54
54
  const aclUrl = resourceUrl ? getAclUrl(resourceUrl, isContainer) : null;
55
55
 
@@ -65,6 +65,11 @@ export function getResponseHeaders({ isContainer = false, etag = null, contentTy
65
65
  const acceptHeaders = getAcceptHeaders(connegEnabled, isContainer);
66
66
  Object.assign(headers, acceptHeaders);
67
67
 
68
+ // Add Updates-Via header for WebSocket notifications discovery
69
+ if (updatesVia) {
70
+ headers['Updates-Via'] = updatesVia;
71
+ }
72
+
68
73
  if (etag) {
69
74
  headers['ETag'] = etag;
70
75
  }
@@ -86,7 +91,7 @@ export function getCorsHeaders(origin) {
86
91
  'Access-Control-Allow-Origin': origin || '*',
87
92
  'Access-Control-Allow-Methods': 'GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS',
88
93
  'Access-Control-Allow-Headers': 'Accept, Authorization, Content-Type, If-Match, If-None-Match, Link, Slug, Origin',
89
- 'Access-Control-Expose-Headers': 'Accept-Patch, Accept-Post, Allow, Content-Type, ETag, Link, Location, WAC-Allow',
94
+ 'Access-Control-Expose-Headers': 'Accept-Patch, Accept-Post, Allow, Content-Type, ETag, Link, Location, Updates-Via, WAC-Allow',
90
95
  'Access-Control-Allow-Credentials': 'true',
91
96
  'Access-Control-Max-Age': '86400'
92
97
  };
@@ -97,9 +102,9 @@ export function getCorsHeaders(origin) {
97
102
  * @param {object} options
98
103
  * @returns {object}
99
104
  */
100
- export function getAllHeaders({ isContainer = false, etag = null, contentType = null, origin = null, resourceUrl = null, wacAllow = null, connegEnabled = false }) {
105
+ export function getAllHeaders({ isContainer = false, etag = null, contentType = null, origin = null, resourceUrl = null, wacAllow = null, connegEnabled = false, updatesVia = null }) {
101
106
  return {
102
- ...getResponseHeaders({ isContainer, etag, contentType, resourceUrl, wacAllow, connegEnabled }),
107
+ ...getResponseHeaders({ isContainer, etag, contentType, resourceUrl, wacAllow, connegEnabled, updatesVia }),
103
108
  ...getCorsHeaders(origin)
104
109
  };
105
110
  }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Resource Events Emitter
3
+ *
4
+ * Singleton EventEmitter for resource change notifications.
5
+ * Handlers emit 'change' events here, WebSocket broadcasts to subscribers.
6
+ */
7
+
8
+ import { EventEmitter } from 'events';
9
+
10
+ // Singleton event emitter for resource changes
11
+ export const resourceEvents = new EventEmitter();
12
+
13
+ // Increase max listeners since many WebSocket connections may subscribe
14
+ resourceEvents.setMaxListeners(1000);
15
+
16
+ /**
17
+ * Emit a resource change event
18
+ * @param {string} resourceUrl - Full URL of the changed resource
19
+ */
20
+ export function emitChange(resourceUrl) {
21
+ resourceEvents.emit('change', resourceUrl);
22
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Notifications Plugin
3
+ *
4
+ * Fastify plugin that adds WebSocket notification support.
5
+ * Implements the legacy "solid-0.1" protocol for SolidOS compatibility.
6
+ *
7
+ * Usage:
8
+ * createServer({ notifications: true })
9
+ *
10
+ * Discovery:
11
+ * OPTIONS /resource returns Updates-Via header with WebSocket URL
12
+ *
13
+ * Client usage:
14
+ * const ws = new WebSocket(updatesViaUrl);
15
+ * ws.send('sub http://example.org/resource');
16
+ * ws.onmessage = (e) => { if (e.data.startsWith('pub ')) ... }
17
+ */
18
+
19
+ import websocket from '@fastify/websocket';
20
+ import { handleWebSocket, getConnectionCount, getSubscriptionCount } from './websocket.js';
21
+ export { emitChange } from './events.js';
22
+
23
+ /**
24
+ * Register the notifications plugin with Fastify
25
+ * @param {FastifyInstance} fastify
26
+ * @param {object} options
27
+ */
28
+ export async function notificationsPlugin(fastify, options) {
29
+ // Register the WebSocket plugin
30
+ await fastify.register(websocket);
31
+
32
+ // WebSocket route for notifications (dedicated path to avoid route conflicts)
33
+ // Clients discover this via Updates-Via header
34
+ // In @fastify/websocket v8, handler receives (connection, request) where connection.socket is the raw WebSocket
35
+ fastify.get('/.notifications', { websocket: true }, (connection, request) => {
36
+ handleWebSocket(connection.socket, request);
37
+ });
38
+
39
+ // Optional: Status endpoint for monitoring
40
+ fastify.get('/.well-known/solid/notifications', async (request, reply) => {
41
+ return {
42
+ connections: getConnectionCount(),
43
+ subscriptions: getSubscriptionCount(),
44
+ protocol: 'solid-0.1'
45
+ };
46
+ });
47
+ }
48
+
49
+ export default notificationsPlugin;
@@ -0,0 +1,183 @@
1
+ /**
2
+ * WebSocket Handler for Solid Notifications
3
+ *
4
+ * Implements the legacy "solid-0.1" protocol used by SolidOS/mashlib.
5
+ *
6
+ * Protocol:
7
+ * - Server sends: "protocol solid-0.1" on connect
8
+ * - Client sends: "sub <uri>" to subscribe
9
+ * - Server sends: "ack <uri>" to acknowledge
10
+ * - Server sends: "pub <uri>" when resource changes
11
+ */
12
+
13
+ import { resourceEvents } from './events.js';
14
+
15
+ // Track subscriptions: WebSocket -> Set<url>
16
+ const subscriptions = new Map();
17
+
18
+ // Reverse lookup: url -> Set<WebSocket>
19
+ const subscribers = new Map();
20
+
21
+ /**
22
+ * Handle new WebSocket connection
23
+ * @param {WebSocket} socket - The WebSocket connection
24
+ * @param {Request} request - The HTTP request
25
+ */
26
+ export function handleWebSocket(socket, request) {
27
+ // Send protocol greeting
28
+ socket.send('protocol solid-0.1');
29
+
30
+ // Initialize subscription set for this socket
31
+ subscriptions.set(socket, new Set());
32
+
33
+ // Handle incoming messages
34
+ socket.on('message', (message) => {
35
+ const msg = message.toString().trim();
36
+
37
+ // Handle subscription request
38
+ if (msg.startsWith('sub ')) {
39
+ const url = msg.slice(4).trim();
40
+ if (url) {
41
+ subscribe(socket, url);
42
+ socket.send(`ack ${url}`);
43
+ }
44
+ }
45
+
46
+ // Handle unsubscribe (optional extension)
47
+ if (msg.startsWith('unsub ')) {
48
+ const url = msg.slice(6).trim();
49
+ if (url) {
50
+ unsubscribe(socket, url);
51
+ }
52
+ }
53
+ });
54
+
55
+ // Clean up on close
56
+ socket.on('close', () => {
57
+ cleanup(socket);
58
+ });
59
+
60
+ // Clean up on error
61
+ socket.on('error', () => {
62
+ cleanup(socket);
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Subscribe a socket to a resource URL
68
+ */
69
+ function subscribe(socket, url) {
70
+ // Add to socket's subscriptions
71
+ const socketSubs = subscriptions.get(socket);
72
+ if (socketSubs) {
73
+ socketSubs.add(url);
74
+ }
75
+
76
+ // Add to URL's subscribers
77
+ if (!subscribers.has(url)) {
78
+ subscribers.set(url, new Set());
79
+ }
80
+ subscribers.get(url).add(socket);
81
+ }
82
+
83
+ /**
84
+ * Unsubscribe a socket from a resource URL
85
+ */
86
+ function unsubscribe(socket, url) {
87
+ // Remove from socket's subscriptions
88
+ const socketSubs = subscriptions.get(socket);
89
+ if (socketSubs) {
90
+ socketSubs.delete(url);
91
+ }
92
+
93
+ // Remove from URL's subscribers
94
+ const urlSubs = subscribers.get(url);
95
+ if (urlSubs) {
96
+ urlSubs.delete(socket);
97
+ if (urlSubs.size === 0) {
98
+ subscribers.delete(url);
99
+ }
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Clean up all subscriptions for a socket
105
+ */
106
+ function cleanup(socket) {
107
+ const urls = subscriptions.get(socket);
108
+ if (urls) {
109
+ for (const url of urls) {
110
+ unsubscribe(socket, url);
111
+ }
112
+ }
113
+ subscriptions.delete(socket);
114
+ }
115
+
116
+ /**
117
+ * Broadcast a change notification to all subscribers of a URL
118
+ * Also notifies subscribers of parent containers
119
+ */
120
+ export function broadcast(url) {
121
+ // Notify direct subscribers
122
+ notifySubscribers(url);
123
+
124
+ // Also notify container subscribers (parent directory)
125
+ // This allows subscribing to a container and getting notified of all child changes
126
+ const containerUrl = getParentContainer(url);
127
+ if (containerUrl && containerUrl !== url) {
128
+ notifySubscribers(containerUrl);
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Send pub message to all subscribers of a URL
134
+ */
135
+ function notifySubscribers(url) {
136
+ const subs = subscribers.get(url);
137
+ if (subs) {
138
+ const message = `pub ${url}`;
139
+ for (const socket of subs) {
140
+ if (socket.readyState === 1) { // WebSocket.OPEN
141
+ try {
142
+ socket.send(message);
143
+ } catch (e) {
144
+ // Socket may have closed, will be cleaned up on close event
145
+ }
146
+ }
147
+ }
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Get parent container URL from a resource URL
153
+ */
154
+ function getParentContainer(url) {
155
+ // Remove trailing slash if present
156
+ const normalized = url.endsWith('/') ? url.slice(0, -1) : url;
157
+ const lastSlash = normalized.lastIndexOf('/');
158
+ if (lastSlash > 0) {
159
+ return normalized.substring(0, lastSlash + 1);
160
+ }
161
+ return null;
162
+ }
163
+
164
+ /**
165
+ * Get count of active subscriptions (for monitoring)
166
+ */
167
+ export function getSubscriptionCount() {
168
+ let count = 0;
169
+ for (const urls of subscriptions.values()) {
170
+ count += urls.size;
171
+ }
172
+ return count;
173
+ }
174
+
175
+ /**
176
+ * Get count of active connections (for monitoring)
177
+ */
178
+ export function getConnectionCount() {
179
+ return subscriptions.size;
180
+ }
181
+
182
+ // Listen to resource change events and broadcast
183
+ resourceEvents.on('change', broadcast);