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
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turtle <-> JSON-LD Conversion
|
|
3
|
+
*
|
|
4
|
+
* Provides bidirectional conversion between Turtle and JSON-LD formats.
|
|
5
|
+
* Uses the N3.js library for parsing and serializing Turtle.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Parser, Writer, DataFactory } from 'n3';
|
|
9
|
+
const { namedNode, literal, blankNode, quad } = DataFactory;
|
|
10
|
+
|
|
11
|
+
// Common prefixes for compact output
|
|
12
|
+
const COMMON_PREFIXES = {
|
|
13
|
+
rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
|
|
14
|
+
rdfs: 'http://www.w3.org/2000/01/rdf-schema#',
|
|
15
|
+
xsd: 'http://www.w3.org/2001/XMLSchema#',
|
|
16
|
+
foaf: 'http://xmlns.com/foaf/0.1/',
|
|
17
|
+
ldp: 'http://www.w3.org/ns/ldp#',
|
|
18
|
+
solid: 'http://www.w3.org/ns/solid/terms#',
|
|
19
|
+
acl: 'http://www.w3.org/ns/auth/acl#',
|
|
20
|
+
pim: 'http://www.w3.org/ns/pim/space#',
|
|
21
|
+
dc: 'http://purl.org/dc/terms/',
|
|
22
|
+
schema: 'http://schema.org/',
|
|
23
|
+
vcard: 'http://www.w3.org/2006/vcard/ns#'
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse Turtle to JSON-LD
|
|
28
|
+
* @param {string} turtle - Turtle content
|
|
29
|
+
* @param {string} baseUri - Base URI for relative references
|
|
30
|
+
* @returns {Promise<object>} JSON-LD document
|
|
31
|
+
*/
|
|
32
|
+
export async function turtleToJsonLd(turtle, baseUri) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const parser = new Parser({ baseIRI: baseUri });
|
|
35
|
+
const quads = [];
|
|
36
|
+
|
|
37
|
+
parser.parse(turtle, (error, quad, prefixes) => {
|
|
38
|
+
if (error) {
|
|
39
|
+
reject(error);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (quad) {
|
|
44
|
+
quads.push(quad);
|
|
45
|
+
} else {
|
|
46
|
+
// Parsing complete
|
|
47
|
+
try {
|
|
48
|
+
const jsonLd = quadsToJsonLd(quads, baseUri, prefixes);
|
|
49
|
+
resolve(jsonLd);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
reject(e);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Convert JSON-LD to Turtle
|
|
60
|
+
* @param {object} jsonLd - JSON-LD document
|
|
61
|
+
* @param {string} baseUri - Base URI for the document
|
|
62
|
+
* @returns {Promise<string>} Turtle content
|
|
63
|
+
*/
|
|
64
|
+
export async function jsonLdToTurtle(jsonLd, baseUri) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
try {
|
|
67
|
+
const quads = jsonLdToQuads(jsonLd, baseUri);
|
|
68
|
+
|
|
69
|
+
const writer = new Writer({
|
|
70
|
+
prefixes: COMMON_PREFIXES,
|
|
71
|
+
baseIRI: baseUri
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
for (const q of quads) {
|
|
75
|
+
writer.addQuad(q);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
writer.end((error, result) => {
|
|
79
|
+
if (error) {
|
|
80
|
+
reject(error);
|
|
81
|
+
} else {
|
|
82
|
+
resolve(result);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
} catch (e) {
|
|
86
|
+
reject(e);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Convert N3.js quads to JSON-LD
|
|
93
|
+
*/
|
|
94
|
+
function quadsToJsonLd(quads, baseUri, prefixes = {}) {
|
|
95
|
+
if (quads.length === 0) {
|
|
96
|
+
return { '@context': buildContext(prefixes) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Group quads by subject
|
|
100
|
+
const subjects = new Map();
|
|
101
|
+
|
|
102
|
+
for (const quad of quads) {
|
|
103
|
+
const subjectKey = quad.subject.value;
|
|
104
|
+
if (!subjects.has(subjectKey)) {
|
|
105
|
+
subjects.set(subjectKey, {
|
|
106
|
+
'@id': makeRelative(quad.subject.value, baseUri),
|
|
107
|
+
_quads: []
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
subjects.get(subjectKey)._quads.push(quad);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Build nodes
|
|
114
|
+
const nodes = [];
|
|
115
|
+
for (const [subjectUri, node] of subjects) {
|
|
116
|
+
const jsonNode = { '@id': node['@id'] };
|
|
117
|
+
|
|
118
|
+
for (const quad of node._quads) {
|
|
119
|
+
const predicate = quad.predicate.value;
|
|
120
|
+
const predicateKey = compactUri(predicate, prefixes);
|
|
121
|
+
|
|
122
|
+
// Handle rdf:type specially
|
|
123
|
+
if (predicate === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type') {
|
|
124
|
+
const typeValue = compactUri(quad.object.value, prefixes);
|
|
125
|
+
if (jsonNode['@type']) {
|
|
126
|
+
if (Array.isArray(jsonNode['@type'])) {
|
|
127
|
+
jsonNode['@type'].push(typeValue);
|
|
128
|
+
} else {
|
|
129
|
+
jsonNode['@type'] = [jsonNode['@type'], typeValue];
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
jsonNode['@type'] = typeValue;
|
|
133
|
+
}
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const objectValue = termToJsonLd(quad.object, baseUri, prefixes);
|
|
138
|
+
|
|
139
|
+
if (jsonNode[predicateKey]) {
|
|
140
|
+
// Multiple values - make array
|
|
141
|
+
if (Array.isArray(jsonNode[predicateKey])) {
|
|
142
|
+
jsonNode[predicateKey].push(objectValue);
|
|
143
|
+
} else {
|
|
144
|
+
jsonNode[predicateKey] = [jsonNode[predicateKey], objectValue];
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
jsonNode[predicateKey] = objectValue;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
nodes.push(jsonNode);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Build result
|
|
155
|
+
const context = buildContext(prefixes);
|
|
156
|
+
|
|
157
|
+
if (nodes.length === 1) {
|
|
158
|
+
return { '@context': context, ...nodes[0] };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { '@context': context, '@graph': nodes };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Convert JSON-LD to N3.js quads
|
|
166
|
+
*/
|
|
167
|
+
function jsonLdToQuads(jsonLd, baseUri) {
|
|
168
|
+
const quads = [];
|
|
169
|
+
const context = jsonLd['@context'] || {};
|
|
170
|
+
|
|
171
|
+
// Handle @graph or single object
|
|
172
|
+
const nodes = jsonLd['@graph'] || [jsonLd];
|
|
173
|
+
|
|
174
|
+
for (const node of nodes) {
|
|
175
|
+
if (!node['@id']) continue;
|
|
176
|
+
|
|
177
|
+
const subjectUri = resolveUri(node['@id'], baseUri);
|
|
178
|
+
const subject = subjectUri.startsWith('_:')
|
|
179
|
+
? blankNode(subjectUri.slice(2))
|
|
180
|
+
: namedNode(subjectUri);
|
|
181
|
+
|
|
182
|
+
// Handle @type
|
|
183
|
+
if (node['@type']) {
|
|
184
|
+
const types = Array.isArray(node['@type']) ? node['@type'] : [node['@type']];
|
|
185
|
+
for (const type of types) {
|
|
186
|
+
const typeUri = expandUri(type, context);
|
|
187
|
+
quads.push(quad(
|
|
188
|
+
subject,
|
|
189
|
+
namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'),
|
|
190
|
+
namedNode(typeUri)
|
|
191
|
+
));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Handle other properties
|
|
196
|
+
for (const [key, value] of Object.entries(node)) {
|
|
197
|
+
if (key.startsWith('@')) continue;
|
|
198
|
+
|
|
199
|
+
const predicateUri = expandUri(key, context);
|
|
200
|
+
const predicate = namedNode(predicateUri);
|
|
201
|
+
|
|
202
|
+
const values = Array.isArray(value) ? value : [value];
|
|
203
|
+
for (const v of values) {
|
|
204
|
+
const object = valueToTerm(v, baseUri, context);
|
|
205
|
+
if (object) {
|
|
206
|
+
quads.push(quad(subject, predicate, object));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return quads;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Convert N3.js term to JSON-LD value
|
|
217
|
+
*/
|
|
218
|
+
function termToJsonLd(term, baseUri, prefixes) {
|
|
219
|
+
if (term.termType === 'NamedNode') {
|
|
220
|
+
const uri = makeRelative(term.value, baseUri);
|
|
221
|
+
// Check if it looks like a URI or should be @id
|
|
222
|
+
if (uri.includes('://') || uri.startsWith('#') || uri.startsWith('/')) {
|
|
223
|
+
return { '@id': uri };
|
|
224
|
+
}
|
|
225
|
+
return { '@id': uri };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (term.termType === 'BlankNode') {
|
|
229
|
+
return { '@id': '_:' + term.value };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (term.termType === 'Literal') {
|
|
233
|
+
// Check for language tag
|
|
234
|
+
if (term.language) {
|
|
235
|
+
return { '@value': term.value, '@language': term.language };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Check for datatype
|
|
239
|
+
const datatype = term.datatype?.value;
|
|
240
|
+
if (datatype) {
|
|
241
|
+
// Handle common XSD types
|
|
242
|
+
if (datatype === 'http://www.w3.org/2001/XMLSchema#integer') {
|
|
243
|
+
return parseInt(term.value, 10);
|
|
244
|
+
}
|
|
245
|
+
if (datatype === 'http://www.w3.org/2001/XMLSchema#decimal' ||
|
|
246
|
+
datatype === 'http://www.w3.org/2001/XMLSchema#double' ||
|
|
247
|
+
datatype === 'http://www.w3.org/2001/XMLSchema#float') {
|
|
248
|
+
return parseFloat(term.value);
|
|
249
|
+
}
|
|
250
|
+
if (datatype === 'http://www.w3.org/2001/XMLSchema#boolean') {
|
|
251
|
+
return term.value === 'true';
|
|
252
|
+
}
|
|
253
|
+
if (datatype === 'http://www.w3.org/2001/XMLSchema#string') {
|
|
254
|
+
return term.value;
|
|
255
|
+
}
|
|
256
|
+
// Other typed literals
|
|
257
|
+
return { '@value': term.value, '@type': compactUri(datatype, prefixes) };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return term.value;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return term.value;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Convert JSON-LD value to N3.js term
|
|
268
|
+
*/
|
|
269
|
+
function valueToTerm(value, baseUri, context) {
|
|
270
|
+
if (value === null || value === undefined) {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Plain values
|
|
275
|
+
if (typeof value === 'string') {
|
|
276
|
+
return literal(value);
|
|
277
|
+
}
|
|
278
|
+
if (typeof value === 'number') {
|
|
279
|
+
if (Number.isInteger(value)) {
|
|
280
|
+
return literal(value.toString(), namedNode('http://www.w3.org/2001/XMLSchema#integer'));
|
|
281
|
+
}
|
|
282
|
+
return literal(value.toString(), namedNode('http://www.w3.org/2001/XMLSchema#decimal'));
|
|
283
|
+
}
|
|
284
|
+
if (typeof value === 'boolean') {
|
|
285
|
+
return literal(value.toString(), namedNode('http://www.w3.org/2001/XMLSchema#boolean'));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Object values
|
|
289
|
+
if (typeof value === 'object') {
|
|
290
|
+
// @id reference
|
|
291
|
+
if (value['@id']) {
|
|
292
|
+
const uri = resolveUri(value['@id'], baseUri);
|
|
293
|
+
return uri.startsWith('_:')
|
|
294
|
+
? blankNode(uri.slice(2))
|
|
295
|
+
: namedNode(uri);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// @value with @language
|
|
299
|
+
if (value['@value'] && value['@language']) {
|
|
300
|
+
return literal(value['@value'], value['@language']);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// @value with @type
|
|
304
|
+
if (value['@value'] && value['@type']) {
|
|
305
|
+
const typeUri = expandUri(value['@type'], context);
|
|
306
|
+
return literal(value['@value'], namedNode(typeUri));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Plain @value
|
|
310
|
+
if (value['@value']) {
|
|
311
|
+
return literal(value['@value']);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Make URI relative to base
|
|
320
|
+
*/
|
|
321
|
+
function makeRelative(uri, baseUri) {
|
|
322
|
+
if (uri.startsWith(baseUri)) {
|
|
323
|
+
const relative = uri.slice(baseUri.length);
|
|
324
|
+
if (relative.startsWith('#') || relative === '') {
|
|
325
|
+
return relative || '.';
|
|
326
|
+
}
|
|
327
|
+
return relative;
|
|
328
|
+
}
|
|
329
|
+
return uri;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Resolve relative URI against base
|
|
334
|
+
*/
|
|
335
|
+
function resolveUri(uri, baseUri) {
|
|
336
|
+
if (uri.startsWith('http://') || uri.startsWith('https://') || uri.startsWith('_:')) {
|
|
337
|
+
return uri;
|
|
338
|
+
}
|
|
339
|
+
if (uri.startsWith('#')) {
|
|
340
|
+
return baseUri + uri;
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
return new URL(uri, baseUri).href;
|
|
344
|
+
} catch {
|
|
345
|
+
return uri;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Expand prefixed URI using context
|
|
351
|
+
*/
|
|
352
|
+
function expandUri(uri, context) {
|
|
353
|
+
if (uri.includes('://')) {
|
|
354
|
+
return uri;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (uri.includes(':')) {
|
|
358
|
+
const [prefix, local] = uri.split(':', 2);
|
|
359
|
+
const ns = context[prefix] || COMMON_PREFIXES[prefix];
|
|
360
|
+
if (ns) {
|
|
361
|
+
return ns + local;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Check if it's a term in context
|
|
366
|
+
if (context[uri]) {
|
|
367
|
+
const expansion = context[uri];
|
|
368
|
+
if (typeof expansion === 'string') {
|
|
369
|
+
return expansion;
|
|
370
|
+
}
|
|
371
|
+
if (expansion['@id']) {
|
|
372
|
+
return expansion['@id'];
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return uri;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Compact URI using prefixes
|
|
381
|
+
*/
|
|
382
|
+
function compactUri(uri, prefixes) {
|
|
383
|
+
// Check custom prefixes first
|
|
384
|
+
for (const [prefix, ns] of Object.entries(prefixes)) {
|
|
385
|
+
if (uri.startsWith(ns)) {
|
|
386
|
+
return prefix + ':' + uri.slice(ns.length);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Check common prefixes
|
|
391
|
+
for (const [prefix, ns] of Object.entries(COMMON_PREFIXES)) {
|
|
392
|
+
if (uri.startsWith(ns)) {
|
|
393
|
+
return prefix + ':' + uri.slice(ns.length);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return uri;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Build JSON-LD @context from prefixes
|
|
402
|
+
*/
|
|
403
|
+
function buildContext(prefixes) {
|
|
404
|
+
const context = { ...COMMON_PREFIXES };
|
|
405
|
+
for (const [prefix, ns] of Object.entries(prefixes)) {
|
|
406
|
+
if (prefix && ns) {
|
|
407
|
+
context[prefix] = ns;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return context;
|
|
411
|
+
}
|
package/src/server.js
CHANGED
|
@@ -3,11 +3,21 @@ 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
|
|
10
|
+
* @param {object} options - Server options
|
|
11
|
+
* @param {boolean} options.logger - Enable logging (default true)
|
|
12
|
+
* @param {boolean} options.conneg - Enable content negotiation for RDF (default false)
|
|
13
|
+
* @param {boolean} options.notifications - Enable WebSocket notifications (default false)
|
|
9
14
|
*/
|
|
10
15
|
export function createServer(options = {}) {
|
|
16
|
+
// Content negotiation is OFF by default - we're a JSON-LD native server
|
|
17
|
+
const connegEnabled = options.conneg ?? false;
|
|
18
|
+
// WebSocket notifications are OFF by default
|
|
19
|
+
const notificationsEnabled = options.notifications ?? false;
|
|
20
|
+
|
|
11
21
|
const fastify = Fastify({
|
|
12
22
|
logger: options.logger ?? true,
|
|
13
23
|
trustProxy: true,
|
|
@@ -20,12 +30,31 @@ export function createServer(options = {}) {
|
|
|
20
30
|
done(null, body);
|
|
21
31
|
});
|
|
22
32
|
|
|
33
|
+
// Attach server config to requests
|
|
34
|
+
fastify.decorateRequest('connegEnabled', null);
|
|
35
|
+
fastify.decorateRequest('notificationsEnabled', null);
|
|
36
|
+
fastify.addHook('onRequest', async (request) => {
|
|
37
|
+
request.connegEnabled = connegEnabled;
|
|
38
|
+
request.notificationsEnabled = notificationsEnabled;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Register WebSocket notifications plugin if enabled
|
|
42
|
+
if (notificationsEnabled) {
|
|
43
|
+
fastify.register(notificationsPlugin);
|
|
44
|
+
}
|
|
45
|
+
|
|
23
46
|
// Global CORS preflight
|
|
24
47
|
fastify.addHook('onRequest', async (request, reply) => {
|
|
25
48
|
// Add CORS headers to all responses
|
|
26
49
|
const corsHeaders = getCorsHeaders(request.headers.origin);
|
|
27
50
|
Object.entries(corsHeaders).forEach(([k, v]) => reply.header(k, v));
|
|
28
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
|
+
|
|
29
58
|
// Handle preflight OPTIONS
|
|
30
59
|
if (request.method === 'OPTIONS') {
|
|
31
60
|
// Add Allow header for LDP compliance
|