javascript-solid-server 0.0.8 → 0.0.9

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.
@@ -13,7 +13,9 @@
13
13
  "Bash(pkill:*)",
14
14
  "Bash(curl:*)",
15
15
  "Bash(npm test:*)",
16
- "Bash(git add:*)"
16
+ "Bash(git add:*)",
17
+ "WebFetch(domain:solid.github.io)",
18
+ "Bash(node:*)"
17
19
  ]
18
20
  }
19
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -10,6 +10,7 @@
10
10
  "test": "node --test --test-concurrency=1"
11
11
  },
12
12
  "dependencies": {
13
+ "@fastify/websocket": "^8.3.1",
13
14
  "fastify": "^4.25.2",
14
15
  "fs-extra": "^11.2.0",
15
16
  "jose": "^6.1.3",
@@ -5,6 +5,7 @@ import { generateProfile, generatePreferences, generateTypeIndex, serialize } fr
5
5
  import { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, serializeAcl } from '../wac/parser.js';
6
6
  import { createToken } from '../auth/token.js';
7
7
  import { canAcceptInput, toJsonLd, getVaryHeader, RDF_TYPES } from '../rdf/conneg.js';
8
+ import { emitChange } from '../notifications/events.js';
8
9
 
9
10
  /**
10
11
  * Handle POST request to container (create new resource)
@@ -97,6 +98,12 @@ export async function handlePost(request, reply) {
97
98
  headers['Vary'] = getVaryHeader(connegEnabled);
98
99
 
99
100
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
101
+
102
+ // Emit change notification for WebSocket subscribers
103
+ if (request.notificationsEnabled) {
104
+ emitChange(resourceUrl);
105
+ }
106
+
100
107
  return reply.code(201).send();
101
108
  }
102
109
 
@@ -11,6 +11,7 @@ import {
11
11
  getVaryHeader,
12
12
  RDF_TYPES
13
13
  } from '../rdf/conneg.js';
14
+ import { emitChange } from '../notifications/events.js';
14
15
 
15
16
  /**
16
17
  * Handle GET request
@@ -226,6 +227,12 @@ export async function handlePut(request, reply) {
226
227
  headers['Vary'] = getVaryHeader(connegEnabled);
227
228
 
228
229
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
230
+
231
+ // Emit change notification for WebSocket subscribers
232
+ if (request.notificationsEnabled) {
233
+ emitChange(resourceUrl);
234
+ }
235
+
229
236
  return reply.code(existed ? 204 : 201).send();
230
237
  }
231
238
 
@@ -250,6 +257,11 @@ export async function handleDelete(request, reply) {
250
257
  const headers = getAllHeaders({ isContainer: false, origin, resourceUrl });
251
258
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
252
259
 
260
+ // Emit change notification for WebSocket subscribers
261
+ if (request.notificationsEnabled) {
262
+ emitChange(resourceUrl);
263
+ }
264
+
253
265
  return reply.code(204).send();
254
266
  }
255
267
 
@@ -365,5 +377,10 @@ export async function handlePatch(request, reply) {
365
377
  const headers = getAllHeaders({ isContainer: false, origin, resourceUrl });
366
378
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
367
379
 
380
+ // Emit change notification for WebSocket subscribers
381
+ if (request.notificationsEnabled) {
382
+ emitChange(resourceUrl);
383
+ }
384
+
368
385
  return reply.code(204).send();
369
386
  }
@@ -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);
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,348 @@
1
+ /**
2
+ * WebSocket Notifications Tests
3
+ *
4
+ * Tests the solid-0.1 WebSocket notification protocol.
5
+ */
6
+
7
+ import { describe, it, before, after } from 'node:test';
8
+ import assert from 'node:assert';
9
+ import { WebSocket } from 'ws';
10
+ import {
11
+ startTestServer,
12
+ stopTestServer,
13
+ request,
14
+ createTestPod,
15
+ getBaseUrl,
16
+ assertStatus,
17
+ assertHeader,
18
+ assertHeaderContains
19
+ } from './helpers.js';
20
+
21
+ describe('WebSocket Notifications (notifications enabled)', () => {
22
+ let wsUrl;
23
+
24
+ before(async () => {
25
+ // Start server with notifications ENABLED
26
+ await startTestServer({ notifications: true });
27
+ await createTestPod('notifytest');
28
+
29
+ // Get WebSocket URL from Updates-Via header
30
+ const res = await request('/notifytest/', { method: 'OPTIONS' });
31
+ wsUrl = res.headers.get('Updates-Via');
32
+ });
33
+
34
+ after(async () => {
35
+ await stopTestServer();
36
+ });
37
+
38
+ describe('Discovery', () => {
39
+ it('should return Updates-Via header in OPTIONS response', async () => {
40
+ const res = await request('/notifytest/public/', { method: 'OPTIONS' });
41
+ assertStatus(res, 204);
42
+ const updatesVia = res.headers.get('Updates-Via');
43
+ assert.ok(updatesVia, 'Should have Updates-Via header');
44
+ assert.ok(updatesVia.startsWith('ws://') || updatesVia.startsWith('wss://'),
45
+ 'Updates-Via should be a WebSocket URL');
46
+ });
47
+
48
+ it('should return Updates-Via header in GET response', async () => {
49
+ const res = await request('/notifytest/');
50
+ assertStatus(res, 200);
51
+ const updatesVia = res.headers.get('Updates-Via');
52
+ assert.ok(updatesVia, 'GET response should have Updates-Via header');
53
+ });
54
+
55
+ it('should expose Updates-Via in CORS headers', async () => {
56
+ const res = await request('/notifytest/', {
57
+ headers: { 'Origin': 'http://example.org' }
58
+ });
59
+ const expose = res.headers.get('Access-Control-Expose-Headers');
60
+ assert.ok(expose && expose.includes('Updates-Via'),
61
+ 'Updates-Via should be in exposed headers');
62
+ });
63
+ });
64
+
65
+ describe('WebSocket Protocol', () => {
66
+ it('should connect and receive protocol greeting', async () => {
67
+ const ws = new WebSocket(wsUrl);
68
+
69
+ const message = await new Promise((resolve, reject) => {
70
+ ws.on('open', () => {});
71
+ ws.on('message', (data) => resolve(data.toString()));
72
+ ws.on('error', reject);
73
+ setTimeout(() => reject(new Error('Timeout')), 5000);
74
+ });
75
+
76
+ assert.strictEqual(message, 'protocol solid-0.1');
77
+ ws.close();
78
+ });
79
+
80
+ it('should acknowledge subscription', async () => {
81
+ const ws = new WebSocket(wsUrl);
82
+ const baseUrl = getBaseUrl();
83
+ const resourceUrl = `${baseUrl}/notifytest/public/test.json`;
84
+
85
+ const messages = [];
86
+
87
+ await new Promise((resolve, reject) => {
88
+ ws.on('open', () => {
89
+ ws.send(`sub ${resourceUrl}`);
90
+ });
91
+ ws.on('message', (data) => {
92
+ messages.push(data.toString());
93
+ if (messages.length >= 2) resolve();
94
+ });
95
+ ws.on('error', reject);
96
+ setTimeout(() => resolve(), 2000);
97
+ });
98
+
99
+ assert.ok(messages.includes('protocol solid-0.1'), 'Should receive protocol greeting');
100
+ assert.ok(messages.some(m => m.startsWith('ack ')), 'Should receive ack');
101
+ ws.close();
102
+ });
103
+ });
104
+
105
+ describe('Notifications', () => {
106
+ it('should receive pub notification on PUT', async () => {
107
+ const ws = new WebSocket(wsUrl);
108
+ const baseUrl = getBaseUrl();
109
+ const resourceUrl = `${baseUrl}/notifytest/public/notify-put.json`;
110
+
111
+ const notifications = [];
112
+
113
+ await new Promise((resolve) => {
114
+ ws.on('open', () => {
115
+ ws.send(`sub ${resourceUrl}`);
116
+ });
117
+ ws.on('message', (data) => {
118
+ const msg = data.toString();
119
+ if (msg.startsWith('pub ')) {
120
+ notifications.push(msg);
121
+ resolve();
122
+ }
123
+ });
124
+
125
+ // Wait for subscription to be established
126
+ setTimeout(async () => {
127
+ // Create the resource
128
+ await request('/notifytest/public/notify-put.json', {
129
+ method: 'PUT',
130
+ headers: { 'Content-Type': 'application/ld+json' },
131
+ body: JSON.stringify({ '@id': '#test', 'http://example.org/p': 'value' }),
132
+ auth: 'notifytest'
133
+ });
134
+ }, 500);
135
+
136
+ setTimeout(() => resolve(), 3000);
137
+ });
138
+
139
+ assert.ok(notifications.length > 0, 'Should receive pub notification');
140
+ assert.ok(notifications[0].includes(resourceUrl), 'Notification should include resource URL');
141
+ ws.close();
142
+ });
143
+
144
+ it('should receive pub notification on PATCH', async () => {
145
+ const baseUrl = getBaseUrl();
146
+ const resourceUrl = `${baseUrl}/notifytest/public/notify-patch2.json`;
147
+
148
+ // First create the resource
149
+ await request('/notifytest/public/notify-patch2.json', {
150
+ method: 'PUT',
151
+ headers: { 'Content-Type': 'application/ld+json' },
152
+ body: JSON.stringify({ '@id': '#test', 'http://example.org/name': 'Original' }),
153
+ auth: 'notifytest'
154
+ });
155
+
156
+ // Create WebSocket AFTER the initial PUT to avoid race condition
157
+ const ws = new WebSocket(wsUrl);
158
+ const notifications = [];
159
+
160
+ await new Promise((resolve) => {
161
+ ws.on('open', () => {
162
+ ws.send(`sub ${resourceUrl}`);
163
+ });
164
+ ws.on('message', (data) => {
165
+ const msg = data.toString();
166
+ if (msg.startsWith('pub ')) {
167
+ notifications.push(msg);
168
+ resolve();
169
+ }
170
+ });
171
+
172
+ // Wait for subscription to be established, then patch
173
+ setTimeout(async () => {
174
+ const patch = `
175
+ @prefix solid: <http://www.w3.org/ns/solid/terms#>.
176
+ _:patch a solid:InsertDeletePatch;
177
+ solid:inserts { <#test> <http://example.org/name> "Updated" }.
178
+ `;
179
+ await request('/notifytest/public/notify-patch2.json', {
180
+ method: 'PATCH',
181
+ headers: { 'Content-Type': 'text/n3' },
182
+ body: patch,
183
+ auth: 'notifytest'
184
+ });
185
+ }, 500);
186
+
187
+ setTimeout(() => resolve(), 3000);
188
+ });
189
+
190
+ assert.ok(notifications.length > 0, 'Should receive pub notification for PATCH');
191
+ ws.close();
192
+ });
193
+
194
+ it('should receive pub notification on DELETE', async () => {
195
+ const baseUrl = getBaseUrl();
196
+ const resourceUrl = `${baseUrl}/notifytest/public/notify-delete2.json`;
197
+
198
+ // First create the resource
199
+ await request('/notifytest/public/notify-delete2.json', {
200
+ method: 'PUT',
201
+ headers: { 'Content-Type': 'application/ld+json' },
202
+ body: JSON.stringify({ '@id': '#test' }),
203
+ auth: 'notifytest'
204
+ });
205
+
206
+ // Create WebSocket AFTER the initial PUT to avoid race condition
207
+ const ws = new WebSocket(wsUrl);
208
+ const notifications = [];
209
+
210
+ await new Promise((resolve) => {
211
+ ws.on('open', () => {
212
+ ws.send(`sub ${resourceUrl}`);
213
+ });
214
+ ws.on('message', (data) => {
215
+ const msg = data.toString();
216
+ if (msg.startsWith('pub ')) {
217
+ notifications.push(msg);
218
+ resolve();
219
+ }
220
+ });
221
+
222
+ // Wait for subscription, then delete
223
+ setTimeout(async () => {
224
+ await request('/notifytest/public/notify-delete2.json', {
225
+ method: 'DELETE',
226
+ auth: 'notifytest'
227
+ });
228
+ }, 500);
229
+
230
+ setTimeout(() => resolve(), 3000);
231
+ });
232
+
233
+ assert.ok(notifications.length > 0, 'Should receive pub notification for DELETE');
234
+ ws.close();
235
+ });
236
+
237
+ it('should receive container notification when child changes', async () => {
238
+ const ws = new WebSocket(wsUrl);
239
+ const baseUrl = getBaseUrl();
240
+ const containerUrl = `${baseUrl}/notifytest/public/`;
241
+
242
+ const notifications = [];
243
+
244
+ await new Promise((resolve) => {
245
+ ws.on('open', () => {
246
+ // Subscribe to container
247
+ ws.send(`sub ${containerUrl}`);
248
+ });
249
+ ws.on('message', (data) => {
250
+ const msg = data.toString();
251
+ if (msg.startsWith('pub ')) {
252
+ notifications.push(msg);
253
+ resolve();
254
+ }
255
+ });
256
+
257
+ // Wait for subscription, then create a child resource
258
+ setTimeout(async () => {
259
+ await request('/notifytest/public/child-resource.json', {
260
+ method: 'PUT',
261
+ headers: { 'Content-Type': 'application/ld+json' },
262
+ body: JSON.stringify({ '@id': '#child' }),
263
+ auth: 'notifytest'
264
+ });
265
+ }, 500);
266
+
267
+ setTimeout(() => resolve(), 3000);
268
+ });
269
+
270
+ assert.ok(notifications.length > 0, 'Container should receive notification for child changes');
271
+ ws.close();
272
+ });
273
+ });
274
+
275
+ describe('Multiple Subscribers', () => {
276
+ it('should notify all subscribers', async () => {
277
+ const ws1 = new WebSocket(wsUrl);
278
+ const ws2 = new WebSocket(wsUrl);
279
+ const baseUrl = getBaseUrl();
280
+ const resourceUrl = `${baseUrl}/notifytest/public/multi-sub.json`;
281
+
282
+ const notifications1 = [];
283
+ const notifications2 = [];
284
+
285
+ await new Promise((resolve) => {
286
+ let ready = 0;
287
+
288
+ const setupWs = (ws, notifications) => {
289
+ ws.on('open', () => {
290
+ ws.send(`sub ${resourceUrl}`);
291
+ });
292
+ ws.on('message', (data) => {
293
+ const msg = data.toString();
294
+ if (msg.startsWith('ack ')) {
295
+ ready++;
296
+ if (ready === 2) {
297
+ // Both subscribed, trigger change
298
+ setTimeout(async () => {
299
+ await request('/notifytest/public/multi-sub.json', {
300
+ method: 'PUT',
301
+ headers: { 'Content-Type': 'application/json' },
302
+ body: JSON.stringify({ test: true }),
303
+ auth: 'notifytest'
304
+ });
305
+ }, 100);
306
+ }
307
+ }
308
+ if (msg.startsWith('pub ')) {
309
+ notifications.push(msg);
310
+ if (notifications1.length > 0 && notifications2.length > 0) {
311
+ resolve();
312
+ }
313
+ }
314
+ });
315
+ };
316
+
317
+ setupWs(ws1, notifications1);
318
+ setupWs(ws2, notifications2);
319
+
320
+ setTimeout(() => resolve(), 4000);
321
+ });
322
+
323
+ assert.ok(notifications1.length > 0, 'First subscriber should receive notification');
324
+ assert.ok(notifications2.length > 0, 'Second subscriber should receive notification');
325
+ ws1.close();
326
+ ws2.close();
327
+ });
328
+ });
329
+ });
330
+
331
+ describe('WebSocket Notifications (notifications disabled - default)', () => {
332
+ before(async () => {
333
+ // Start server with notifications DISABLED (default)
334
+ await startTestServer({ notifications: false });
335
+ await createTestPod('nonotify');
336
+ });
337
+
338
+ after(async () => {
339
+ await stopTestServer();
340
+ });
341
+
342
+ it('should NOT return Updates-Via header when notifications disabled', async () => {
343
+ const res = await request('/nonotify/', { method: 'OPTIONS' });
344
+ assertStatus(res, 204);
345
+ const updatesVia = res.headers.get('Updates-Via');
346
+ assert.strictEqual(updatesVia, null, 'Should NOT have Updates-Via header when disabled');
347
+ });
348
+ });