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.
- package/.claude/settings.local.json +3 -1
- package/README.md +167 -146
- package/package.json +4 -2
- package/src/handlers/container.js +41 -3
- package/src/handlers/resource.js +104 -7
- package/src/ldp/headers.js +16 -9
- package/src/notifications/events.js +22 -0
- package/src/notifications/index.js +49 -0
- package/src/notifications/websocket.js +183 -0
- package/src/rdf/conneg.js +215 -0
- package/src/rdf/turtle.js +411 -0
- package/src/server.js +29 -0
- package/test/conneg.test.js +289 -0
- package/test/helpers.js +4 -2
- package/test/notifications.test.js +348 -0
package/src/ldp/headers.js
CHANGED
|
@@ -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
|
-
|
|
63
|
-
|
|
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
|
+
}
|