javascript-solid-server 0.0.7 → 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.
@@ -2,6 +2,8 @@
2
2
  * LDP (Linked Data Platform) header utilities
3
3
  */
4
4
 
5
+ import { getAcceptHeaders } from '../rdf/conneg.js';
6
+
5
7
  const LDP = 'http://www.w3.org/ns/ldp#';
6
8
 
7
9
  /**
@@ -47,20 +49,25 @@ export function getAclUrl(resourceUrl, isContainer) {
47
49
  * @param {object} options
48
50
  * @returns {object}
49
51
  */
50
- export function getResponseHeaders({ isContainer = false, etag = null, contentType = null, resourceUrl = null, wacAllow = null }) {
52
+ export function getResponseHeaders({ isContainer = false, etag = null, contentType = null, resourceUrl = null, wacAllow = null, connegEnabled = false, updatesVia = null }) {
51
53
  // Calculate ACL URL if resource URL provided
52
54
  const aclUrl = resourceUrl ? getAclUrl(resourceUrl, isContainer) : null;
53
55
 
54
56
  const headers = {
55
57
  'Link': getLinkHeader(isContainer, aclUrl),
56
58
  'WAC-Allow': wacAllow || 'user="read write append control", public="read write append"',
57
- 'Accept-Patch': 'application/sparql-update',
58
- 'Allow': 'GET, HEAD, PUT, DELETE, OPTIONS' + (isContainer ? ', POST' : ''),
59
- 'Vary': 'Accept, Authorization, Origin'
59
+ 'Accept-Patch': 'text/n3, application/sparql-update',
60
+ 'Allow': 'GET, HEAD, PUT, DELETE, PATCH, OPTIONS' + (isContainer ? ', POST' : ''),
61
+ 'Vary': connegEnabled ? 'Accept, Authorization, Origin' : 'Authorization, Origin'
60
62
  };
61
63
 
62
- if (isContainer) {
63
- headers['Accept-Post'] = '*/*';
64
+ // Add Accept-* headers (conneg-aware)
65
+ const acceptHeaders = getAcceptHeaders(connegEnabled, isContainer);
66
+ Object.assign(headers, acceptHeaders);
67
+
68
+ // Add Updates-Via header for WebSocket notifications discovery
69
+ if (updatesVia) {
70
+ headers['Updates-Via'] = updatesVia;
64
71
  }
65
72
 
66
73
  if (etag) {
@@ -84,7 +91,7 @@ export function getCorsHeaders(origin) {
84
91
  'Access-Control-Allow-Origin': origin || '*',
85
92
  'Access-Control-Allow-Methods': 'GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS',
86
93
  'Access-Control-Allow-Headers': 'Accept, Authorization, Content-Type, If-Match, If-None-Match, Link, Slug, Origin',
87
- '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',
88
95
  'Access-Control-Allow-Credentials': 'true',
89
96
  'Access-Control-Max-Age': '86400'
90
97
  };
@@ -95,9 +102,9 @@ export function getCorsHeaders(origin) {
95
102
  * @param {object} options
96
103
  * @returns {object}
97
104
  */
98
- export function getAllHeaders({ isContainer = false, etag = null, contentType = null, origin = null, resourceUrl = null, wacAllow = null }) {
105
+ export function getAllHeaders({ isContainer = false, etag = null, contentType = null, origin = null, resourceUrl = null, wacAllow = null, connegEnabled = false, updatesVia = null }) {
99
106
  return {
100
- ...getResponseHeaders({ isContainer, etag, contentType, resourceUrl, wacAllow }),
107
+ ...getResponseHeaders({ isContainer, etag, contentType, resourceUrl, wacAllow, connegEnabled, updatesVia }),
101
108
  ...getCorsHeaders(origin)
102
109
  };
103
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);
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Content Negotiation for RDF Resources
3
+ *
4
+ * Handles Accept header parsing and format selection.
5
+ * OFF by default - this is a JSON-LD native implementation.
6
+ * Enable with { conneg: true } in server options.
7
+ */
8
+
9
+ import { turtleToJsonLd, jsonLdToTurtle } from './turtle.js';
10
+
11
+ // RDF content types we support
12
+ export const RDF_TYPES = {
13
+ JSON_LD: 'application/ld+json',
14
+ TURTLE: 'text/turtle',
15
+ N3: 'text/n3',
16
+ NTRIPLES: 'application/n-triples',
17
+ RDF_XML: 'application/rdf+xml' // Not supported, but recognized
18
+ };
19
+
20
+ // Content types we can serve (when conneg enabled)
21
+ const SUPPORTED_OUTPUT = [RDF_TYPES.JSON_LD, RDF_TYPES.TURTLE];
22
+
23
+ // Content types we can accept for input (when conneg enabled)
24
+ const SUPPORTED_INPUT = [RDF_TYPES.JSON_LD, RDF_TYPES.TURTLE, RDF_TYPES.N3];
25
+
26
+ /**
27
+ * Parse Accept header and select best content type
28
+ * @param {string} acceptHeader - Accept header value
29
+ * @param {boolean} connegEnabled - Whether content negotiation is enabled
30
+ * @returns {string} Selected content type
31
+ */
32
+ export function selectContentType(acceptHeader, connegEnabled = false) {
33
+ // If conneg disabled, always return JSON-LD
34
+ if (!connegEnabled) {
35
+ return RDF_TYPES.JSON_LD;
36
+ }
37
+
38
+ if (!acceptHeader) {
39
+ return RDF_TYPES.JSON_LD;
40
+ }
41
+
42
+ // Parse Accept header
43
+ const accepts = parseAcceptHeader(acceptHeader);
44
+
45
+ // Find best match
46
+ for (const { type } of accepts) {
47
+ if (type === '*/*' || type === 'application/*') {
48
+ return RDF_TYPES.JSON_LD;
49
+ }
50
+ if (SUPPORTED_OUTPUT.includes(type)) {
51
+ return type;
52
+ }
53
+ // Handle text/* preference
54
+ if (type === 'text/*') {
55
+ return RDF_TYPES.TURTLE;
56
+ }
57
+ }
58
+
59
+ // Default to JSON-LD
60
+ return RDF_TYPES.JSON_LD;
61
+ }
62
+
63
+ /**
64
+ * Parse Accept header into sorted list
65
+ */
66
+ function parseAcceptHeader(header) {
67
+ const types = header.split(',').map(part => {
68
+ const [type, ...params] = part.trim().split(';');
69
+ let q = 1;
70
+
71
+ for (const param of params) {
72
+ const [key, value] = param.trim().split('=');
73
+ if (key === 'q') {
74
+ q = parseFloat(value) || 0;
75
+ }
76
+ }
77
+
78
+ return { type: type.trim().toLowerCase(), q };
79
+ });
80
+
81
+ // Sort by q value descending
82
+ return types.sort((a, b) => b.q - a.q);
83
+ }
84
+
85
+ /**
86
+ * Check if content type is RDF
87
+ */
88
+ export function isRdfType(contentType) {
89
+ if (!contentType) return false;
90
+ const type = contentType.split(';')[0].trim().toLowerCase();
91
+ return Object.values(RDF_TYPES).includes(type) ||
92
+ type === 'application/json'; // Treat as JSON-LD
93
+ }
94
+
95
+ /**
96
+ * Check if we can accept this input type for RDF resources
97
+ * Non-RDF content types are always accepted (passthrough)
98
+ */
99
+ export function canAcceptInput(contentType, connegEnabled = false) {
100
+ if (!contentType) return true; // No content type = accept
101
+
102
+ const type = contentType.split(';')[0].trim().toLowerCase();
103
+
104
+ // Always accept JSON-LD and JSON
105
+ if (type === RDF_TYPES.JSON_LD || type === 'application/json') {
106
+ return true;
107
+ }
108
+
109
+ // Check if it's an RDF type we need to handle
110
+ const isRdf = Object.values(RDF_TYPES).includes(type);
111
+
112
+ // Non-RDF types are accepted as-is (passthrough)
113
+ if (!isRdf) {
114
+ return true;
115
+ }
116
+
117
+ // RDF types other than JSON-LD only if conneg enabled
118
+ if (connegEnabled) {
119
+ return SUPPORTED_INPUT.includes(type);
120
+ }
121
+
122
+ // RDF type but conneg disabled - reject (should use JSON-LD)
123
+ return false;
124
+ }
125
+
126
+ /**
127
+ * Convert content to JSON-LD (internal storage format)
128
+ * @param {Buffer|string} content - Input content
129
+ * @param {string} contentType - Content-Type header
130
+ * @param {string} baseUri - Base URI
131
+ * @param {boolean} connegEnabled - Whether conneg is enabled
132
+ * @returns {Promise<object>} JSON-LD document
133
+ */
134
+ export async function toJsonLd(content, contentType, baseUri, connegEnabled = false) {
135
+ const type = (contentType || '').split(';')[0].trim().toLowerCase();
136
+ const text = Buffer.isBuffer(content) ? content.toString() : content;
137
+
138
+ // JSON-LD or JSON
139
+ if (type === RDF_TYPES.JSON_LD || type === 'application/json' || !type) {
140
+ return JSON.parse(text);
141
+ }
142
+
143
+ // Turtle/N3 - only if conneg enabled
144
+ if (connegEnabled && (type === RDF_TYPES.TURTLE || type === RDF_TYPES.N3)) {
145
+ return turtleToJsonLd(text, baseUri);
146
+ }
147
+
148
+ throw new Error(`Unsupported content type: ${type}`);
149
+ }
150
+
151
+ /**
152
+ * Convert JSON-LD to requested format
153
+ * @param {object} jsonLd - JSON-LD document
154
+ * @param {string} targetType - Target content type
155
+ * @param {string} baseUri - Base URI
156
+ * @param {boolean} connegEnabled - Whether conneg is enabled
157
+ * @returns {Promise<{content: string, contentType: string}>}
158
+ */
159
+ export async function fromJsonLd(jsonLd, targetType, baseUri, connegEnabled = false) {
160
+ // If conneg disabled, always output JSON-LD
161
+ if (!connegEnabled) {
162
+ return {
163
+ content: JSON.stringify(jsonLd, null, 2),
164
+ contentType: RDF_TYPES.JSON_LD
165
+ };
166
+ }
167
+
168
+ // JSON-LD
169
+ if (targetType === RDF_TYPES.JSON_LD || !targetType) {
170
+ return {
171
+ content: JSON.stringify(jsonLd, null, 2),
172
+ contentType: RDF_TYPES.JSON_LD
173
+ };
174
+ }
175
+
176
+ // Turtle
177
+ if (targetType === RDF_TYPES.TURTLE) {
178
+ const turtle = await jsonLdToTurtle(jsonLd, baseUri);
179
+ return { content: turtle, contentType: RDF_TYPES.TURTLE };
180
+ }
181
+
182
+ // Fallback to JSON-LD
183
+ return {
184
+ content: JSON.stringify(jsonLd, null, 2),
185
+ contentType: RDF_TYPES.JSON_LD
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Get Vary header value for content negotiation
191
+ */
192
+ export function getVaryHeader(connegEnabled) {
193
+ return connegEnabled ? 'Accept, Origin' : 'Origin';
194
+ }
195
+
196
+ /**
197
+ * Get Accept-* headers for responses
198
+ */
199
+ export function getAcceptHeaders(connegEnabled, isContainer = false) {
200
+ const headers = {};
201
+
202
+ if (isContainer) {
203
+ headers['Accept-Post'] = connegEnabled
204
+ ? `${RDF_TYPES.JSON_LD}, ${RDF_TYPES.TURTLE}, */*`
205
+ : `${RDF_TYPES.JSON_LD}, */*`;
206
+ }
207
+
208
+ headers['Accept-Put'] = connegEnabled
209
+ ? `${RDF_TYPES.JSON_LD}, ${RDF_TYPES.TURTLE}, */*`
210
+ : `${RDF_TYPES.JSON_LD}, */*`;
211
+
212
+ headers['Accept-Patch'] = 'text/n3, application/sparql-update';
213
+
214
+ return headers;
215
+ }