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
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,
|
|
@@ -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
|
|
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
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
}
|
package/src/ldp/headers.js
CHANGED
|
@@ -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);
|