javascript-solid-server 0.0.155 → 0.0.156

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.155",
3
+ "version": "0.0.156",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -149,17 +149,18 @@ export async function handleGet(request, reply) {
149
149
  const content = await storage.read(indexPath);
150
150
  const indexStats = await storage.stat(indexPath);
151
151
 
152
- // Check if RDF format requested via content negotiation
152
+ // Pick the negotiated RDF type using q-aware Accept parsing. The
153
+ // naive `acceptHeader.includes('text/turtle')` we used to do here
154
+ // ignored q-weights — `Accept: application/ld+json, text/turtle;q=0.1`
155
+ // would still pick Turtle even though JSON-LD was preferred (#325).
153
156
  const acceptHeader = request.headers.accept || '';
154
- const wantsTurtle = connegEnabled && (
155
- acceptHeader.includes('text/turtle') ||
156
- acceptHeader.includes('text/n3') ||
157
- acceptHeader.includes('application/n-triples')
158
- );
159
- const wantsJsonLd = connegEnabled && (
160
- acceptHeader.includes('application/ld+json') ||
161
- acceptHeader.includes('application/json')
162
- );
157
+ const negotiated = connegEnabled
158
+ ? selectContentType(acceptHeader, true)
159
+ : null;
160
+ const wantsTurtle = negotiated === RDF_TYPES.TURTLE
161
+ || negotiated === RDF_TYPES.N3
162
+ || negotiated === 'application/n-triples';
163
+ const wantsJsonLd = negotiated === RDF_TYPES.JSON_LD;
163
164
 
164
165
  if (wantsTurtle || wantsJsonLd) {
165
166
  // Extract JSON-LD from HTML data island
@@ -257,13 +258,14 @@ export async function handleGet(request, reply) {
257
258
  return reply.type('text/html').send(html);
258
259
  }
259
260
 
260
- // Check if Turtle/N3 format is requested via content negotiation
261
+ // Pick the negotiated RDF type using q-aware Accept parsing (#325).
261
262
  const acceptHeader = request.headers.accept || '';
262
- const wantsTurtle = connegEnabled && (
263
- acceptHeader.includes('text/turtle') ||
264
- acceptHeader.includes('text/n3') ||
265
- acceptHeader.includes('application/n-triples')
266
- );
263
+ const negotiated = connegEnabled
264
+ ? selectContentType(acceptHeader, true)
265
+ : null;
266
+ const wantsTurtle = negotiated === RDF_TYPES.TURTLE
267
+ || negotiated === RDF_TYPES.N3
268
+ || negotiated === 'application/n-triples';
267
269
 
268
270
  if (wantsTurtle) {
269
271
  // Convert container JSON-LD to Turtle
@@ -383,11 +385,14 @@ export async function handleGet(request, reply) {
383
385
  if (connegEnabled) {
384
386
  const contentStr = content.toString();
385
387
  const acceptHeader = request.headers.accept || '';
386
- // Serve Turtle if: URL ends with .ttl OR Accept header requests it
387
- const wantsTurtle = urlPath.endsWith('.ttl') ||
388
- acceptHeader.includes('text/turtle') ||
389
- acceptHeader.includes('text/n3') ||
390
- acceptHeader.includes('application/n-triples');
388
+ // Serve Turtle if: URL ends with .ttl OR Accept's q-weighted top
389
+ // RDF type is Turtle/N3 (#325 — naive substring matching ignored
390
+ // q-weights and would pick Turtle whenever it appeared in Accept).
391
+ const negotiated = selectContentType(acceptHeader, true);
392
+ const wantsTurtle = urlPath.endsWith('.ttl')
393
+ || negotiated === RDF_TYPES.TURTLE
394
+ || negotiated === RDF_TYPES.N3
395
+ || negotiated === 'application/n-triples';
391
396
 
392
397
  // Check if this is HTML with JSON-LD data island
393
398
  const isHtmlWithDataIsland = contentStr.trimStart().startsWith('<!DOCTYPE') ||
@@ -505,24 +510,31 @@ export async function handleHead(request, reply) {
505
510
  let contentType;
506
511
 
507
512
  if (stats.isDirectory) {
508
- // For directories with index.html, determine content type based on Accept header
509
513
  const indexPath = storagePath.endsWith('/') ? `${storagePath}index.html` : `${storagePath}/index.html`;
510
514
  const indexExists = await storage.exists(indexPath);
515
+ const acceptHeader = request.headers.accept || '';
511
516
 
512
- if (indexExists && connegEnabled) {
513
- const acceptHeader = request.headers.accept || '';
514
- const wantsTurtle = acceptHeader.includes('text/turtle') ||
515
- acceptHeader.includes('text/n3') ||
516
- acceptHeader.includes('application/n-triples');
517
- const wantsJsonLd = acceptHeader.includes('application/ld+json') ||
518
- acceptHeader.includes('application/json');
517
+ if (connegEnabled) {
518
+ // HEAD must mirror what GET would emit; otherwise client caches and
519
+ // RDF-aware tooling key off a content-type that doesn't match the
520
+ // body they'll see on the next GET (#325). Use q-aware Accept
521
+ // parsing for both the index.html and listing branches.
522
+ const negotiated = selectContentType(acceptHeader, true);
523
+ const wantsTurtle = negotiated === RDF_TYPES.TURTLE
524
+ || negotiated === RDF_TYPES.N3
525
+ || negotiated === 'application/n-triples';
526
+ const wantsJsonLd = negotiated === RDF_TYPES.JSON_LD;
519
527
 
520
528
  if (wantsTurtle) {
521
529
  contentType = 'text/turtle';
522
530
  } else if (wantsJsonLd) {
523
- contentType = 'application/ld+json';
531
+ // For an index.html container, only override to JSON-LD if the
532
+ // Accept header explicitly asked for JSON; otherwise fall back
533
+ // to text/html so HEAD matches the index.html that GET serves.
534
+ const explicitJson = /\b(application\/ld\+json|application\/json)\b/i.test(acceptHeader);
535
+ contentType = (indexExists && !explicitJson) ? 'text/html' : 'application/ld+json';
524
536
  } else {
525
- contentType = 'text/html';
537
+ contentType = indexExists ? 'text/html' : 'application/ld+json';
526
538
  }
527
539
  } else if (indexExists) {
528
540
  contentType = 'text/html';
@@ -354,3 +354,95 @@ describe('Content Negotiation (conneg disabled - default)', () => {
354
354
  });
355
355
  });
356
356
  });
357
+
358
+ // Regression coverage for #325 — q-weighted Accept and HEAD/GET parity.
359
+ // Previously the conneg dispatcher used naive substring matching on the
360
+ // Accept header, so any Accept that mentioned text/turtle (even at q=0.1
361
+ // alongside q=1.0 application/ld+json) returned Turtle. Separately, HEAD
362
+ // on a container without an index.html hard-coded application/ld+json,
363
+ // so HEAD and GET disagreed on content-type for the same URL.
364
+ describe('Content Negotiation — q-weights and HEAD/GET parity (#325)', () => {
365
+ before(async () => {
366
+ await startTestServer({ conneg: true });
367
+ await createTestPod('qwtest');
368
+ });
369
+ after(async () => { await stopTestServer(); });
370
+
371
+ function ct(res) {
372
+ return (res.headers.get('content-type') || '').split(';')[0].trim();
373
+ }
374
+
375
+ describe('container — q-weight respected', () => {
376
+ it('Accept: jsonld q=1.0, turtle q=0.1 → JSON-LD', async () => {
377
+ const res = await request('/qwtest/', {
378
+ headers: { Accept: 'application/ld+json;q=1.0, text/turtle;q=0.1' }
379
+ });
380
+ assertStatus(res, 200);
381
+ assert.strictEqual(ct(res), 'application/ld+json');
382
+ const body = await res.text();
383
+ assert.ok(body.trimStart().startsWith('{'),
384
+ `body should be JSON, got: ${body.slice(0, 80)}`);
385
+ });
386
+
387
+ it('Accept: jsonld, turtle;q=0.5 → JSON-LD wins (downstream repro)', async () => {
388
+ const res = await request('/qwtest/', {
389
+ headers: { Accept: 'application/ld+json, text/turtle;q=0.5' }
390
+ });
391
+ assert.strictEqual(ct(res), 'application/ld+json');
392
+ const body = await res.text();
393
+ assert.ok(body.trimStart().startsWith('{'),
394
+ `body should be JSON, got: ${body.slice(0, 80)}`);
395
+ });
396
+
397
+ it('Accept: turtle (explicit) → Turtle', async () => {
398
+ const res = await request('/qwtest/', { headers: { Accept: 'text/turtle' } });
399
+ assert.strictEqual(ct(res), 'text/turtle');
400
+ const body = await res.text();
401
+ assert.ok(body.trimStart().startsWith('@prefix'),
402
+ `body should be Turtle, got: ${body.slice(0, 80)}`);
403
+ });
404
+
405
+ it('no Accept → JSON-LD (native default)', async () => {
406
+ const res = await request('/qwtest/');
407
+ assert.strictEqual(ct(res), 'application/ld+json');
408
+ });
409
+ });
410
+
411
+ describe('container — HEAD content-type matches GET', () => {
412
+ const cases = [
413
+ ['no Accept', {}],
414
+ ['jsonld preferred', { Accept: 'application/ld+json;q=1.0, text/turtle;q=0.1' }],
415
+ ['turtle preferred', { Accept: 'text/turtle' }],
416
+ ['mixed (q=0.5)', { Accept: 'application/ld+json, text/turtle;q=0.5' }]
417
+ ];
418
+ for (const [label, headers] of cases) {
419
+ it(`HEAD === GET content-type — ${label}`, async () => {
420
+ const get = await request('/qwtest/', { headers });
421
+ const head = await request('/qwtest/', { method: 'HEAD', headers });
422
+ assert.strictEqual(get.status, 200);
423
+ assert.strictEqual(head.status, 200);
424
+ assert.strictEqual(ct(head), ct(get),
425
+ `HEAD ct (${ct(head)}) must equal GET ct (${ct(get)}) for ${label}`);
426
+ });
427
+ }
428
+ });
429
+
430
+ describe('container — auth path matches anonymous', () => {
431
+ it('GET with auth returns same content-type as without auth (turtle case)', async () => {
432
+ const headers = { Accept: 'text/turtle' };
433
+ const anon = await request('/qwtest/', { headers });
434
+ const authed = await request('/qwtest/', { headers, auth: 'qwtest' });
435
+ assert.strictEqual(ct(anon), 'text/turtle');
436
+ assert.strictEqual(ct(authed), ct(anon),
437
+ 'authenticated GET must report the same content-type as anonymous');
438
+ });
439
+
440
+ it('GET with auth returns same content-type as without auth (jsonld case)', async () => {
441
+ const headers = { Accept: 'application/ld+json;q=1.0, text/turtle;q=0.1' };
442
+ const anon = await request('/qwtest/', { headers });
443
+ const authed = await request('/qwtest/', { headers, auth: 'qwtest' });
444
+ assert.strictEqual(ct(anon), 'application/ld+json');
445
+ assert.strictEqual(ct(authed), ct(anon));
446
+ });
447
+ });
448
+ });