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.
- package/.claude/settings.local.json +3 -1
- package/package.json +2 -1
- package/src/handlers/container.js +7 -0
- package/src/handlers/resource.js +17 -0
- 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/server.js +17 -0
- package/test/notifications.test.js +348 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "javascript-solid-server",
|
|
3
|
-
"version": "0.0.
|
|
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
|
|
package/src/handlers/resource.js
CHANGED
|
@@ -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
|
}
|
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);
|
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
|
+
});
|