@hashtree/worker 0.1.1 → 0.1.2

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.
Files changed (44) hide show
  1. package/dist/capabilities/blossomTransport.d.ts.map +1 -1
  2. package/dist/capabilities/blossomTransport.js +41 -8
  3. package/dist/capabilities/blossomTransport.js.map +1 -1
  4. package/dist/htree-path.d.ts +13 -0
  5. package/dist/htree-path.d.ts.map +1 -0
  6. package/dist/htree-path.js +38 -0
  7. package/dist/htree-path.js.map +1 -0
  8. package/dist/iris/mediaHandler.d.ts +25 -1
  9. package/dist/iris/mediaHandler.d.ts.map +1 -1
  10. package/dist/iris/mediaHandler.js +838 -102
  11. package/dist/iris/mediaHandler.js.map +1 -1
  12. package/dist/iris/ndk.d.ts +2 -1
  13. package/dist/iris/ndk.d.ts.map +1 -1
  14. package/dist/iris/ndk.js +12 -6
  15. package/dist/iris/ndk.js.map +1 -1
  16. package/dist/iris/protocol.d.ts +3 -0
  17. package/dist/iris/protocol.d.ts.map +1 -1
  18. package/dist/iris/protocol.js.map +1 -1
  19. package/dist/iris/publicAssetUrl.d.ts +6 -0
  20. package/dist/iris/publicAssetUrl.d.ts.map +1 -0
  21. package/dist/iris/publicAssetUrl.js +14 -0
  22. package/dist/iris/publicAssetUrl.js.map +1 -0
  23. package/dist/iris/rootPathResolver.d.ts +9 -0
  24. package/dist/iris/rootPathResolver.d.ts.map +1 -0
  25. package/dist/iris/rootPathResolver.js +32 -0
  26. package/dist/iris/rootPathResolver.js.map +1 -0
  27. package/dist/iris/treeRootCache.d.ts +9 -1
  28. package/dist/iris/treeRootCache.d.ts.map +1 -1
  29. package/dist/iris/treeRootCache.js +57 -2
  30. package/dist/iris/treeRootCache.js.map +1 -1
  31. package/dist/iris/treeRootSubscription.d.ts +5 -0
  32. package/dist/iris/treeRootSubscription.d.ts.map +1 -1
  33. package/dist/iris/treeRootSubscription.js +279 -15
  34. package/dist/iris/treeRootSubscription.js.map +1 -1
  35. package/dist/iris/worker.js +12 -27
  36. package/dist/iris/worker.js.map +1 -1
  37. package/dist/p2p/signaling.d.ts.map +1 -1
  38. package/dist/p2p/signaling.js +31 -4
  39. package/dist/p2p/signaling.js.map +1 -1
  40. package/dist/p2p/webrtcController.d.ts +21 -4
  41. package/dist/p2p/webrtcController.d.ts.map +1 -1
  42. package/dist/p2p/webrtcController.js +199 -59
  43. package/dist/p2p/webrtcController.js.map +1 -1
  44. package/package.json +12 -3
@@ -5,20 +5,73 @@
5
5
  * Handles media requests from the service worker via MessagePort.
6
6
  * Supports both direct CID-based requests and path-based requests with live streaming.
7
7
  */
8
- import { getCachedRoot } from './treeRootCache';
9
- import { subscribeToTreeRoots } from './treeRootSubscription';
8
+ import { getCachedRoot, onCachedRootUpdate } from './treeRootCache';
9
+ import { resolveTreeRootNow, subscribeToTreeRoots } from './treeRootSubscription';
10
10
  import { getErrorMessage } from './utils/errorMessage';
11
- import { nhashDecode } from '@hashtree/core';
11
+ import { nhashDecode, toHex } from '@hashtree/core';
12
12
  import { nip19 } from 'nostr-tools';
13
+ import { LRUCache } from './utils/lruCache';
13
14
  // Thumbnail filename patterns to look for (in priority order)
14
15
  const THUMBNAIL_PATTERNS = ['thumbnail.jpg', 'thumbnail.webp', 'thumbnail.png', 'thumbnail.jpeg'];
16
+ const PLAYABLE_MEDIA_EXTENSION_SET = new Set([
17
+ '.mp4',
18
+ '.webm',
19
+ '.mkv',
20
+ '.mov',
21
+ '.avi',
22
+ '.m4v',
23
+ '.ogv',
24
+ '.3gp',
25
+ '.mp3',
26
+ '.wav',
27
+ '.flac',
28
+ '.m4a',
29
+ '.aac',
30
+ '.ogg',
31
+ '.oga',
32
+ '.opus',
33
+ ]);
34
+ const MIME_TYPES = {
35
+ mp4: 'video/mp4',
36
+ m4v: 'video/mp4',
37
+ webm: 'video/webm',
38
+ ogg: 'video/ogg',
39
+ ogv: 'video/ogg',
40
+ mov: 'video/quicktime',
41
+ avi: 'video/x-msvideo',
42
+ mkv: 'video/x-matroska',
43
+ mp3: 'audio/mpeg',
44
+ wav: 'audio/wav',
45
+ flac: 'audio/flac',
46
+ m4a: 'audio/mp4',
47
+ aac: 'audio/aac',
48
+ oga: 'audio/ogg',
49
+ opus: 'audio/ogg',
50
+ jpg: 'image/jpeg',
51
+ jpeg: 'image/jpeg',
52
+ png: 'image/png',
53
+ webp: 'image/webp',
54
+ };
15
55
  // Timeout for considering a stream "done" (no updates)
16
56
  const LIVE_STREAM_TIMEOUT = 10000; // 10 seconds
17
57
  const ROOT_WAIT_TIMEOUT_MS = 15000;
18
- const ROOT_WAIT_INTERVAL_MS = 200;
58
+ const DIRECTORY_PROBE_TIMEOUT_MS = 1000;
59
+ const IMMUTABLE_LOOKUP_CACHE_HIT_TTL_MS = 5 * 60 * 1000;
60
+ const IMMUTABLE_LOOKUP_CACHE_MISS_TTL_MS = 1000;
61
+ const DIRECTORY_CACHE_SIZE = 1024;
62
+ const RESOLVED_ENTRY_CACHE_SIZE = 2048;
63
+ const MUTABLE_RESOLVED_ENTRY_CACHE_SIZE = 2048;
64
+ const FILE_SIZE_CACHE_SIZE = 1024;
65
+ const THUMBNAIL_PATH_CACHE_SIZE = 1024;
19
66
  // Chunk size for streaming to media port
20
67
  const MEDIA_CHUNK_SIZE = 256 * 1024; // 256KB chunks - matches videoChunker's firstChunkSize
21
68
  const activeMediaStreams = new Map();
69
+ const inflightRootWaits = new Map();
70
+ const directoryLookupCache = createAsyncLookupCache(DIRECTORY_CACHE_SIZE);
71
+ const resolvedEntryLookupCache = createAsyncLookupCache(RESOLVED_ENTRY_CACHE_SIZE);
72
+ const mutableResolvedEntryLookupCache = createAsyncLookupCache(MUTABLE_RESOLVED_ENTRY_CACHE_SIZE);
73
+ const fileSizeLookupCache = createAsyncLookupCache(FILE_SIZE_CACHE_SIZE);
74
+ const thumbnailPathLookupCache = createAsyncLookupCache(THUMBNAIL_PATH_CACHE_SIZE);
22
75
  let mediaPort = null;
23
76
  let tree = null;
24
77
  let mediaDebugEnabled = false;
@@ -32,11 +85,73 @@ function logMediaDebug(event, data) {
32
85
  console.log(`[WorkerMedia] ${event}`);
33
86
  }
34
87
  }
88
+ function createAsyncLookupCache(maxEntries) {
89
+ return {
90
+ values: new LRUCache(maxEntries),
91
+ inflight: new Map(),
92
+ };
93
+ }
94
+ function clearAsyncLookupCache(cache) {
95
+ cache.values.clear();
96
+ cache.inflight.clear();
97
+ }
98
+ function clearMediaLookupCaches() {
99
+ inflightRootWaits.clear();
100
+ clearAsyncLookupCache(directoryLookupCache);
101
+ clearAsyncLookupCache(resolvedEntryLookupCache);
102
+ clearAsyncLookupCache(mutableResolvedEntryLookupCache);
103
+ clearAsyncLookupCache(fileSizeLookupCache);
104
+ clearAsyncLookupCache(thumbnailPathLookupCache);
105
+ }
106
+ function getLookupTtlMs(value) {
107
+ return value == null ? IMMUTABLE_LOOKUP_CACHE_MISS_TTL_MS : IMMUTABLE_LOOKUP_CACHE_HIT_TTL_MS;
108
+ }
109
+ async function loadCachedLookup(cache, key, loader, ttlMsForValue) {
110
+ const now = Date.now();
111
+ const cached = cache.values.get(key);
112
+ if (cached && cached.expiresAt > now) {
113
+ return cached.value;
114
+ }
115
+ if (cached) {
116
+ cache.values.delete(key);
117
+ }
118
+ const inflight = cache.inflight.get(key);
119
+ if (inflight) {
120
+ return inflight;
121
+ }
122
+ const pending = loader()
123
+ .then((value) => {
124
+ const ttlMs = ttlMsForValue(value);
125
+ if (ttlMs > 0) {
126
+ cache.values.set(key, {
127
+ value,
128
+ expiresAt: Date.now() + ttlMs,
129
+ });
130
+ }
131
+ return value;
132
+ })
133
+ .finally(() => {
134
+ cache.inflight.delete(key);
135
+ });
136
+ cache.inflight.set(key, pending);
137
+ return pending;
138
+ }
139
+ function cidCacheKey(cid) {
140
+ return cid.key ? `${toHex(cid.hash)}?k=${toHex(cid.key)}` : toHex(cid.hash);
141
+ }
142
+ function sameCid(a, b) {
143
+ if (!a && !b)
144
+ return true;
145
+ if (!a || !b)
146
+ return false;
147
+ return cidCacheKey(a) === cidCacheKey(b);
148
+ }
35
149
  /**
36
150
  * Initialize the media handler with the HashTree instance
37
151
  */
38
152
  export function initMediaHandler(hashTree) {
39
153
  tree = hashTree;
154
+ clearMediaLookupCaches();
40
155
  }
41
156
  /**
42
157
  * Register a MessagePort from the service worker for media streaming
@@ -44,6 +159,7 @@ export function initMediaHandler(hashTree) {
44
159
  export function registerMediaPort(port, debug) {
45
160
  mediaPort = port;
46
161
  mediaDebugEnabled = !!debug;
162
+ port.start?.();
47
163
  port.onmessage = async (e) => {
48
164
  const req = e.data;
49
165
  if (req.type === 'hashtree-file') {
@@ -137,7 +253,10 @@ async function handleMediaRequestByPath(req) {
137
253
  }
138
254
  // Navigate to file within tree if path specified
139
255
  if (filePath) {
140
- const resolved = await tree.resolvePath(cid, filePath);
256
+ const resolved = await resolveMutableTreeEntry(npub, treeName, filePath, {
257
+ allowSingleSegmentRootFallback: false,
258
+ expectedMimeType: mimeType,
259
+ });
141
260
  if (!resolved) {
142
261
  mediaPort.postMessage({
143
262
  type: 'error',
@@ -231,7 +350,7 @@ function watchTreeRootForStream(npub, treeName, filePath, streamInfo) {
231
350
  // Navigate to file
232
351
  let fileCid = cid;
233
352
  if (filePath) {
234
- const resolved = await tree.resolvePath(cid, filePath);
353
+ const resolved = await resolvePathFromDirectoryListings(cid, filePath);
235
354
  if (!resolved) {
236
355
  scheduleNext();
237
356
  return;
@@ -275,7 +394,7 @@ function watchTreeRootForStream(npub, treeName, filePath, streamInfo) {
275
394
  async function handleSwFileRequest(req) {
276
395
  if (!tree || !mediaPort)
277
396
  return;
278
- const { requestId, npub, nhash, treeName, path, start, end, mimeType, download } = req;
397
+ const { requestId, npub, nhash, treeName, path, start, end, rangeHeader, mimeType, download } = req;
279
398
  logMediaDebug('sw:request', {
280
399
  requestId,
281
400
  npub: npub ?? null,
@@ -284,81 +403,80 @@ async function handleSwFileRequest(req) {
284
403
  path,
285
404
  start,
286
405
  end: end ?? null,
406
+ rangeHeader: rangeHeader ?? null,
287
407
  mimeType,
288
408
  download: !!download,
289
409
  });
290
410
  try {
291
- let cid = null;
411
+ let resolvedEntry = null;
292
412
  if (nhash) {
293
413
  // Direct nhash request - decode to CID
294
414
  const rootCid = nhashDecode(nhash);
295
- // If path provided AND it contains a slash, navigate within the nhash directory
296
- // Single filename without slashes is just a hint for MIME type - use rootCid directly
297
- if (path && path.includes('/')) {
298
- const entry = await tree.resolvePath(rootCid, path);
299
- if (!entry) {
300
- sendSwError(requestId, 404, `File not found: ${path}`);
301
- return;
302
- }
303
- cid = entry.cid;
304
- }
305
- else if (path && !path.includes('/')) {
306
- // Try to resolve as file within directory first
307
- const entry = await tree.resolvePath(rootCid, path);
308
- cid = entry ? entry.cid : rootCid;
309
- }
310
- else {
311
- cid = rootCid;
415
+ resolvedEntry = await resolveEntryWithinRoot(rootCid, path || '', {
416
+ allowSingleSegmentRootFallback: true,
417
+ expectedMimeType: mimeType,
418
+ });
419
+ if (!resolvedEntry) {
420
+ sendSwError(requestId, 404, `File not found: ${path}`);
421
+ return;
312
422
  }
313
423
  }
314
424
  else if (npub && treeName) {
315
- // Npub-based request - resolve through cached root
316
- const rootCid = await waitForCachedRoot(npub, treeName);
317
- if (!rootCid) {
318
- sendSwError(requestId, 404, 'Tree not found');
425
+ // Npub-based request - resolve through mutable root history when needed
426
+ resolvedEntry = await resolveMutableTreeEntry(npub, treeName, path || '', {
427
+ allowSingleSegmentRootFallback: false,
428
+ expectedMimeType: mimeType,
429
+ });
430
+ if (!resolvedEntry) {
431
+ sendSwError(requestId, 404, 'File not found');
319
432
  return;
320
433
  }
321
- // Handle thumbnail requests without extension
322
- let resolvedPath = path || '';
323
- if (resolvedPath.endsWith('/thumbnail') || resolvedPath === 'thumbnail') {
324
- const dirPath = resolvedPath.endsWith('/thumbnail')
325
- ? resolvedPath.slice(0, -'/thumbnail'.length)
326
- : '';
327
- const actualPath = await findThumbnailInDir(rootCid, dirPath);
328
- if (actualPath) {
329
- resolvedPath = actualPath;
330
- }
331
- }
332
- // Navigate to file
333
- if (resolvedPath) {
334
- const entry = await tree.resolvePath(rootCid, resolvedPath);
335
- if (!entry) {
336
- sendSwError(requestId, 404, 'File not found');
337
- return;
338
- }
339
- cid = entry.cid;
340
- }
341
- else {
342
- cid = rootCid;
343
- }
344
434
  }
345
- if (!cid) {
435
+ if (!resolvedEntry?.cid) {
346
436
  sendSwError(requestId, 400, 'Invalid request');
347
437
  return;
348
438
  }
349
439
  // Get file size
350
- const totalSize = await getFileSize(cid);
440
+ // Directory listings may report 0 for non-empty files when the actual byte size
441
+ // is not embedded in the tree node metadata. Treat that as unknown and resolve
442
+ // the real size from the file itself before building HTTP headers.
443
+ const knownSize = typeof resolvedEntry.size === 'number' && resolvedEntry.size > 0
444
+ ? resolvedEntry.size
445
+ : null;
446
+ const effectivePath = resolvedEntry.path ?? path;
447
+ const effectiveMimeType = (mimeType === 'application/octet-stream'
448
+ || isThumbnailAliasPath(path)
449
+ || isVideoAliasPath(path))
450
+ ? guessMimeTypeFromPath(effectivePath)
451
+ : mimeType;
452
+ const totalSize = knownSize ?? await getFileSize(resolvedEntry.cid);
351
453
  if (totalSize === null) {
352
- sendSwError(requestId, 404, 'File data not found');
454
+ const canBufferWholeFile = !rangeHeader && start === 0 && end === undefined;
455
+ if (!canBufferWholeFile || typeof tree.readFile !== 'function') {
456
+ sendSwError(requestId, 404, 'File data not found');
457
+ return;
458
+ }
459
+ const fullData = await tree.readFile(resolvedEntry.cid);
460
+ if (!fullData) {
461
+ sendSwError(requestId, 404, 'File data not found');
462
+ return;
463
+ }
464
+ sendBufferedSwResponse(requestId, fullData, {
465
+ npub,
466
+ path: effectivePath,
467
+ mimeType: effectiveMimeType,
468
+ download,
469
+ });
353
470
  return;
354
471
  }
355
472
  // Stream the content
356
- await streamSwResponse(requestId, cid, totalSize, {
473
+ await streamSwResponse(requestId, resolvedEntry.cid, totalSize, {
357
474
  npub,
358
- path,
475
+ path: effectivePath,
359
476
  start,
360
477
  end,
361
- mimeType,
478
+ rangeHeader,
479
+ mimeType: effectiveMimeType,
362
480
  download,
363
481
  });
364
482
  }
@@ -367,22 +485,72 @@ async function handleSwFileRequest(req) {
367
485
  }
368
486
  }
369
487
  async function waitForCachedRoot(npub, treeName) {
370
- let cached = await getCachedRoot(npub, treeName);
488
+ const cached = await getCachedRoot(npub, treeName);
371
489
  if (cached)
372
490
  return cached;
491
+ const cacheKey = `${npub}/${treeName}`;
492
+ const inflight = inflightRootWaits.get(cacheKey);
493
+ if (inflight) {
494
+ return inflight;
495
+ }
373
496
  const pubkey = decodeNpubToPubkey(npub);
374
497
  if (pubkey) {
375
498
  subscribeToTreeRoots(pubkey);
376
499
  }
377
- const deadline = Date.now() + ROOT_WAIT_TIMEOUT_MS;
378
- while (Date.now() < deadline) {
379
- await new Promise((resolve) => setTimeout(resolve, ROOT_WAIT_INTERVAL_MS));
380
- cached = await getCachedRoot(npub, treeName);
381
- if (cached)
382
- return cached;
383
- }
384
- logMediaDebug('root:timeout', { npub, treeName });
385
- return null;
500
+ const pending = new Promise((resolve) => {
501
+ let settled = false;
502
+ const finish = (cid) => {
503
+ if (settled)
504
+ return;
505
+ settled = true;
506
+ clearTimeout(timeout);
507
+ unsubscribe();
508
+ resolve(cid);
509
+ };
510
+ const unsubscribe = onCachedRootUpdate((updatedNpub, updatedTreeName, cid) => {
511
+ if (updatedNpub === npub && updatedTreeName === treeName && cid) {
512
+ finish(cid);
513
+ }
514
+ });
515
+ const timeout = setTimeout(() => {
516
+ logMediaDebug('root:timeout', { npub, treeName });
517
+ finish(null);
518
+ }, ROOT_WAIT_TIMEOUT_MS);
519
+ void getCachedRoot(npub, treeName).then((current) => {
520
+ if (current) {
521
+ finish(current);
522
+ }
523
+ });
524
+ void resolveTreeRootNow(npub, treeName, ROOT_WAIT_TIMEOUT_MS).then((resolved) => {
525
+ if (resolved) {
526
+ finish(resolved);
527
+ }
528
+ }).catch(() => { });
529
+ }).finally(() => {
530
+ inflightRootWaits.delete(cacheKey);
531
+ });
532
+ inflightRootWaits.set(cacheKey, pending);
533
+ return pending;
534
+ }
535
+ async function resolveMutableTreeEntry(npub, treeName, path, options) {
536
+ const currentRoot = await waitForCachedRoot(npub, treeName);
537
+ const cacheKey = [
538
+ npub,
539
+ treeName,
540
+ currentRoot ? cidCacheKey(currentRoot) : 'none',
541
+ options?.allowSingleSegmentRootFallback ? 'root-fallback' : 'strict',
542
+ options?.expectedMimeType ?? '',
543
+ path,
544
+ ].join('|');
545
+ return loadCachedLookup(mutableResolvedEntryLookupCache, cacheKey, async () => {
546
+ if (currentRoot) {
547
+ const currentEntry = await resolveEntryWithinRoot(currentRoot, path, options);
548
+ if (currentEntry) {
549
+ return currentEntry;
550
+ }
551
+ }
552
+ return null;
553
+ }, (value) => getLookupTtlMs(value));
386
554
  }
387
555
  function decodeNpubToPubkey(npub) {
388
556
  if (!npub.startsWith('npub1'))
@@ -397,6 +565,405 @@ function decodeNpubToPubkey(npub) {
397
565
  return null;
398
566
  }
399
567
  }
568
+ async function resolveEntryWithinRoot(rootCid, path, options) {
569
+ if (!tree)
570
+ return null;
571
+ const cacheKey = [
572
+ cidCacheKey(rootCid),
573
+ options?.allowSingleSegmentRootFallback ? 'root-fallback' : 'strict',
574
+ path,
575
+ ].join('|');
576
+ return loadCachedLookup(resolvedEntryLookupCache, cacheKey, async () => {
577
+ if (!path) {
578
+ return { cid: rootCid };
579
+ }
580
+ const resolvedThumbnail = await resolveThumbnailAliasEntry(rootCid, path);
581
+ if (resolvedThumbnail) {
582
+ return resolvedThumbnail;
583
+ }
584
+ if (isThumbnailAliasPath(path)) {
585
+ return null;
586
+ }
587
+ const resolvedPlayable = await resolvePlayableAliasEntry(rootCid, path);
588
+ if (resolvedPlayable) {
589
+ return resolvedPlayable;
590
+ }
591
+ if (isVideoAliasPath(path)) {
592
+ return null;
593
+ }
594
+ if (options?.allowSingleSegmentRootFallback &&
595
+ await canFallbackToRootBlob(rootCid, path, path, options?.expectedMimeType)) {
596
+ const isDirectory = await canListDirectory(rootCid);
597
+ if (!isDirectory) {
598
+ return { cid: rootCid };
599
+ }
600
+ }
601
+ if (options?.allowSingleSegmentRootFallback &&
602
+ !path.includes('/') &&
603
+ isExactThumbnailFilenamePath(path) &&
604
+ !(await canListDirectory(rootCid))) {
605
+ return null;
606
+ }
607
+ const entry = await resolvePathFromDirectoryListings(rootCid, path);
608
+ if (entry) {
609
+ return entry;
610
+ }
611
+ if (options?.allowSingleSegmentRootFallback &&
612
+ await canFallbackToRootBlob(rootCid, path, path, options?.expectedMimeType)) {
613
+ return { cid: rootCid };
614
+ }
615
+ return null;
616
+ }, (value) => getLookupTtlMs(value));
617
+ }
618
+ async function resolveCidWithinRoot(rootCid, path, options) {
619
+ return (await resolveEntryWithinRoot(rootCid, path, options))?.cid ?? null;
620
+ }
621
+ async function resolvePathFromDirectoryListings(rootCid, path) {
622
+ if (!tree)
623
+ return null;
624
+ const parts = path.split('/').filter(Boolean);
625
+ if (parts.length === 0) {
626
+ return { cid: rootCid };
627
+ }
628
+ let currentCid = rootCid;
629
+ for (let i = 0; i < parts.length; i += 1) {
630
+ const entries = await loadDirectoryListing(currentCid);
631
+ if (!entries) {
632
+ return null;
633
+ }
634
+ const entry = entries.find((candidate) => candidate.name === parts[i]);
635
+ if (!entry?.cid) {
636
+ return null;
637
+ }
638
+ if (i === parts.length - 1) {
639
+ return { cid: entry.cid, size: entry.size, path: parts.slice(0, i + 1).join('/') };
640
+ }
641
+ currentCid = entry.cid;
642
+ }
643
+ return null;
644
+ }
645
+ function hasImageBlobSignature(blob) {
646
+ if (blob.length >= 3 && blob[0] === 0xff && blob[1] === 0xd8 && blob[2] === 0xff) {
647
+ return true;
648
+ }
649
+ if (blob.length >= 8
650
+ && blob[0] === 0x89
651
+ && blob[1] === 0x50
652
+ && blob[2] === 0x4e
653
+ && blob[3] === 0x47
654
+ && blob[4] === 0x0d
655
+ && blob[5] === 0x0a
656
+ && blob[6] === 0x1a
657
+ && blob[7] === 0x0a) {
658
+ return true;
659
+ }
660
+ if (blob.length >= 12
661
+ && blob[0] === 0x52
662
+ && blob[1] === 0x49
663
+ && blob[2] === 0x46
664
+ && blob[3] === 0x46
665
+ && blob[8] === 0x57
666
+ && blob[9] === 0x45
667
+ && blob[10] === 0x42
668
+ && blob[11] === 0x50) {
669
+ return true;
670
+ }
671
+ if (blob.length >= 6
672
+ && blob[0] === 0x47
673
+ && blob[1] === 0x49
674
+ && blob[2] === 0x46
675
+ && blob[3] === 0x38
676
+ && (blob[4] === 0x37 || blob[4] === 0x39)
677
+ && blob[5] === 0x61) {
678
+ return true;
679
+ }
680
+ return false;
681
+ }
682
+ function readAscii(blob, start, end) {
683
+ return String.fromCharCode(...blob.slice(start, end));
684
+ }
685
+ function sniffPlayableMediaExtension(blob) {
686
+ if (!blob.length)
687
+ return null;
688
+ if (blob.length >= 12 && readAscii(blob, 4, 8) === 'ftyp') {
689
+ const brand = readAscii(blob, 8, 12).toLowerCase();
690
+ if (brand.startsWith('m4a'))
691
+ return '.m4a';
692
+ if (brand.startsWith('qt'))
693
+ return '.mov';
694
+ return '.mp4';
695
+ }
696
+ if (blob.length >= 4
697
+ && blob[0] === 0x1a
698
+ && blob[1] === 0x45
699
+ && blob[2] === 0xdf
700
+ && blob[3] === 0xa3) {
701
+ const lowerHeader = readAscii(blob, 0, Math.min(blob.length, 64)).toLowerCase();
702
+ return lowerHeader.includes('webm') ? '.webm' : '.mkv';
703
+ }
704
+ if (blob.length >= 4 && readAscii(blob, 0, 4) === 'OggS') {
705
+ return '.ogg';
706
+ }
707
+ if (blob.length >= 4 && readAscii(blob, 0, 4) === 'fLaC') {
708
+ return '.flac';
709
+ }
710
+ if (blob.length >= 12
711
+ && readAscii(blob, 0, 4) === 'RIFF'
712
+ && readAscii(blob, 8, 12) === 'WAVE') {
713
+ return '.wav';
714
+ }
715
+ if (blob.length >= 3 && readAscii(blob, 0, 3) === 'ID3') {
716
+ return '.mp3';
717
+ }
718
+ if (blob.length >= 2 && blob[0] === 0xff && (blob[1] & 0xf6) === 0xf0) {
719
+ return '.aac';
720
+ }
721
+ if (blob.length >= 2 && blob[0] === 0xff && (blob[1] & 0xe0) === 0xe0) {
722
+ return '.mp3';
723
+ }
724
+ return null;
725
+ }
726
+ function guessMimeTypeFromPath(path) {
727
+ const ext = path?.split('.').pop()?.toLowerCase() ?? '';
728
+ return MIME_TYPES[ext] || 'application/octet-stream';
729
+ }
730
+ async function canFallbackToRootBlob(rootCid, resolvedPath, originalPath, expectedMimeType) {
731
+ if (resolvedPath !== originalPath)
732
+ return false;
733
+ if (resolvedPath.includes('/'))
734
+ return false;
735
+ if (isExactThumbnailFilenamePath(resolvedPath)) {
736
+ if (!expectedMimeType?.startsWith('image/')) {
737
+ return false;
738
+ }
739
+ if (!tree) {
740
+ return false;
741
+ }
742
+ try {
743
+ if (typeof tree.readFileRange === 'function') {
744
+ const header = await tree.readFileRange(rootCid, 0, 64);
745
+ return !!header && hasImageBlobSignature(header);
746
+ }
747
+ if (typeof tree.getBlob === 'function') {
748
+ const blob = await tree.getBlob(rootCid.hash);
749
+ return !!blob && hasImageBlobSignature(blob);
750
+ }
751
+ return false;
752
+ }
753
+ catch {
754
+ return false;
755
+ }
756
+ }
757
+ return /\.[A-Za-z0-9]{1,16}$/.test(resolvedPath);
758
+ }
759
+ function isThumbnailAliasPath(path) {
760
+ return path === 'thumbnail' || path.endsWith('/thumbnail');
761
+ }
762
+ function isVideoAliasPath(path) {
763
+ return path === 'video' || path.endsWith('/video');
764
+ }
765
+ function isExactThumbnailFilenamePath(path) {
766
+ const fileName = path.split('/').filter(Boolean).at(-1)?.toLowerCase() ?? '';
767
+ return fileName.startsWith('thumbnail.');
768
+ }
769
+ function isImageFileName(fileName) {
770
+ const normalized = fileName.trim().toLowerCase();
771
+ return normalized.endsWith('.jpg')
772
+ || normalized.endsWith('.jpeg')
773
+ || normalized.endsWith('.png')
774
+ || normalized.endsWith('.webp');
775
+ }
776
+ function findThumbnailFileEntry(entries) {
777
+ for (const pattern of THUMBNAIL_PATTERNS) {
778
+ const directMatch = entries.find((entry) => entry.name === pattern && entry.cid);
779
+ if (directMatch?.cid) {
780
+ return directMatch;
781
+ }
782
+ }
783
+ return entries.find((entry) => isImageFileName(entry.name) && entry.cid) ?? null;
784
+ }
785
+ function resolveEmbeddedThumbnailLookup(value, entries, dirPath) {
786
+ if (typeof value !== 'string') {
787
+ return null;
788
+ }
789
+ const trimmed = value.trim();
790
+ if (!trimmed) {
791
+ return null;
792
+ }
793
+ if (trimmed.startsWith('nhash1')) {
794
+ try {
795
+ return {
796
+ path: dirPath ? `${dirPath}/thumbnail` : 'thumbnail',
797
+ cid: nhashDecode(trimmed),
798
+ };
799
+ }
800
+ catch {
801
+ return null;
802
+ }
803
+ }
804
+ const normalized = trimmed
805
+ .split('?')[0]
806
+ ?.split('#')[0]
807
+ ?.split('/')
808
+ .filter(Boolean)
809
+ .at(-1);
810
+ if (!normalized) {
811
+ return null;
812
+ }
813
+ const entry = entries.find((candidate) => candidate.name === normalized && candidate.cid);
814
+ if (!entry?.cid) {
815
+ return null;
816
+ }
817
+ return {
818
+ path: dirPath ? `${dirPath}/${entry.name}` : entry.name,
819
+ cid: entry.cid,
820
+ size: entry.size,
821
+ };
822
+ }
823
+ async function resolveThumbnailLookupFromMetadata(entries, dirPath) {
824
+ if (!tree) {
825
+ return null;
826
+ }
827
+ const playableEntry = entries.find((entry) => isPlayableMediaFileName(entry.name));
828
+ const playableThumbnail = playableEntry?.meta && typeof playableEntry.meta.thumbnail === 'string'
829
+ ? playableEntry.meta.thumbnail
830
+ : null;
831
+ const embeddedPlayableThumbnail = resolveEmbeddedThumbnailLookup(playableThumbnail, entries, dirPath);
832
+ if (embeddedPlayableThumbnail) {
833
+ return embeddedPlayableThumbnail;
834
+ }
835
+ for (const metadataName of ['metadata.json', 'info.json']) {
836
+ const metadataEntry = entries.find((entry) => entry.name === metadataName && entry.cid);
837
+ if (!metadataEntry?.cid) {
838
+ continue;
839
+ }
840
+ try {
841
+ const metadataData = await tree.readFile(metadataEntry.cid);
842
+ if (!metadataData) {
843
+ continue;
844
+ }
845
+ const parsed = JSON.parse(new TextDecoder().decode(metadataData));
846
+ const embeddedThumbnail = resolveEmbeddedThumbnailLookup(parsed?.thumbnail, entries, dirPath);
847
+ if (embeddedThumbnail) {
848
+ return embeddedThumbnail;
849
+ }
850
+ }
851
+ catch {
852
+ continue;
853
+ }
854
+ }
855
+ return null;
856
+ }
857
+ async function findThumbnailLookupInEntries(entries, dirPath) {
858
+ const directThumbnail = findThumbnailFileEntry(entries);
859
+ if (directThumbnail?.cid) {
860
+ return {
861
+ path: dirPath ? `${dirPath}/${directThumbnail.name}` : directThumbnail.name,
862
+ cid: directThumbnail.cid,
863
+ size: directThumbnail.size,
864
+ };
865
+ }
866
+ return await resolveThumbnailLookupFromMetadata(entries, dirPath);
867
+ }
868
+ async function detectDirectPlayableLookup(cid, dirPath) {
869
+ if (!tree || typeof tree.readFileRange !== 'function') {
870
+ return null;
871
+ }
872
+ try {
873
+ const header = await tree.readFileRange(cid, 0, 64);
874
+ if (!(header instanceof Uint8Array) || header.length === 0) {
875
+ return null;
876
+ }
877
+ const extension = sniffPlayableMediaExtension(header);
878
+ if (!extension) {
879
+ return null;
880
+ }
881
+ const fileName = `video${extension}`;
882
+ return {
883
+ cid,
884
+ path: dirPath ? `${dirPath}/${fileName}` : fileName,
885
+ };
886
+ }
887
+ catch {
888
+ return null;
889
+ }
890
+ }
891
+ function findPlayableLookupInEntries(entries, dirPath) {
892
+ const playableEntry = entries.find((entry) => isPlayableMediaFileName(entry.name) && entry.cid);
893
+ if (!playableEntry?.cid) {
894
+ return null;
895
+ }
896
+ return {
897
+ cid: playableEntry.cid,
898
+ size: playableEntry.size,
899
+ path: dirPath ? `${dirPath}/${playableEntry.name}` : playableEntry.name,
900
+ };
901
+ }
902
+ async function normalizeAliasPath(rootCid, path) {
903
+ if (!path)
904
+ return '';
905
+ const resolvedThumbnail = await resolveThumbnailAliasEntry(rootCid, path);
906
+ if (resolvedThumbnail) {
907
+ return resolvedThumbnail.path;
908
+ }
909
+ const resolvedPlayable = await resolvePlayableAliasEntry(rootCid, path);
910
+ if (resolvedPlayable?.path) {
911
+ return resolvedPlayable.path;
912
+ }
913
+ return path;
914
+ }
915
+ async function canListDirectory(rootCid) {
916
+ if (!tree)
917
+ return false;
918
+ try {
919
+ if ('isDirectory' in tree && typeof tree.isDirectory === 'function') {
920
+ return await tree.isDirectory(rootCid);
921
+ }
922
+ const entries = await listDirectoryWithProbeTimeout(rootCid);
923
+ return Array.isArray(entries);
924
+ }
925
+ catch {
926
+ return false;
927
+ }
928
+ }
929
+ export const __test__ = {
930
+ resolveCidWithinRoot,
931
+ resolveMutableTreeEntry,
932
+ normalizeAliasPath,
933
+ canListDirectory,
934
+ waitForCachedRoot,
935
+ };
936
+ async function resolveThumbnailAliasEntry(rootCid, path) {
937
+ if (!isThumbnailAliasPath(path)) {
938
+ return null;
939
+ }
940
+ const dirPath = path.endsWith('/thumbnail')
941
+ ? path.slice(0, -'/thumbnail'.length)
942
+ : '';
943
+ return findThumbnailLookupInDir(rootCid, dirPath);
944
+ }
945
+ async function resolvePlayableAliasEntry(rootCid, path) {
946
+ if (!isVideoAliasPath(path)) {
947
+ return null;
948
+ }
949
+ const dirPath = path.endsWith('/video')
950
+ ? path.slice(0, -'/video'.length)
951
+ : '';
952
+ return findPlayableLookupInDir(rootCid, dirPath);
953
+ }
954
+ async function loadDirectoryListing(cid) {
955
+ if (!tree)
956
+ return null;
957
+ return loadCachedLookup(directoryLookupCache, cidCacheKey(cid), async () => tree.listDirectory(cid), (value) => getLookupTtlMs(value));
958
+ }
959
+ async function listDirectoryWithProbeTimeout(cid) {
960
+ if (!tree)
961
+ return null;
962
+ return Promise.race([
963
+ loadDirectoryListing(cid),
964
+ new Promise((resolve) => setTimeout(() => resolve(null), DIRECTORY_PROBE_TIMEOUT_MS)),
965
+ ]);
966
+ }
400
967
  /**
401
968
  * Send error response to SW
402
969
  */
@@ -417,46 +984,112 @@ function sendSwError(requestId, status, message) {
417
984
  async function getFileSize(cid) {
418
985
  if (!tree)
419
986
  return null;
420
- const treeNode = await tree.getTreeNode(cid);
421
- if (treeNode) {
422
- // Chunked file - sum link sizes from decrypted tree node
423
- return treeNode.links.reduce((sum, l) => sum + l.size, 0);
424
- }
425
- // Single blob - fetch to check existence and get size
426
- const blob = await tree.getBlob(cid.hash);
427
- if (!blob)
428
- return null;
429
- // For encrypted blobs, decrypted size = encrypted size - 16 (nonce overhead)
430
- return cid.key ? Math.max(0, blob.length - 16) : blob.length;
987
+ return loadCachedLookup(fileSizeLookupCache, cidCacheKey(cid), async () => {
988
+ const treeNode = await tree.getTreeNode(cid);
989
+ if (treeNode) {
990
+ // Chunked file - sum link sizes from decrypted tree node
991
+ return treeNode.links.reduce((sum, l) => sum + l.size, 0);
992
+ }
993
+ // Single blob - fetch to check existence and get size
994
+ const blob = await tree.getBlob(cid.hash);
995
+ if (!blob)
996
+ return null;
997
+ // For encrypted blobs, decrypted size = encrypted size - 16 (nonce overhead)
998
+ return cid.key ? Math.max(0, blob.length - 16) : blob.length;
999
+ }, (value) => getLookupTtlMs(value));
431
1000
  }
432
1001
  /**
433
1002
  * Find actual thumbnail file in a directory
434
1003
  */
435
- async function findThumbnailInDir(rootCid, dirPath) {
1004
+ async function findThumbnailLookupInDir(rootCid, dirPath) {
436
1005
  if (!tree)
437
1006
  return null;
438
- try {
439
- // Get directory CID
440
- const dirEntry = dirPath
441
- ? await tree.resolvePath(rootCid, dirPath)
442
- : { cid: rootCid };
443
- if (!dirEntry)
1007
+ return loadCachedLookup(thumbnailPathLookupCache, `${cidCacheKey(rootCid)}|${dirPath}`, async () => {
1008
+ try {
1009
+ const dirEntry = dirPath
1010
+ ? await resolvePathFromDirectoryListings(rootCid, dirPath)
1011
+ : { cid: rootCid };
1012
+ if (!dirEntry)
1013
+ return null;
1014
+ const entries = await listDirectoryWithProbeTimeout(dirEntry.cid);
1015
+ if (!entries)
1016
+ return null;
1017
+ const rootThumbnail = await findThumbnailLookupInEntries(entries, dirPath);
1018
+ if (rootThumbnail) {
1019
+ return rootThumbnail;
1020
+ }
1021
+ const hasPlayableMediaFile = entries.some((entry) => isPlayableMediaFileName(entry.name));
1022
+ if (!hasPlayableMediaFile && entries.length > 0) {
1023
+ const sortedEntries = [...entries].sort((a, b) => a.name.localeCompare(b.name));
1024
+ for (const entry of sortedEntries.slice(0, 3)) {
1025
+ if (entry.name.endsWith('.json') || entry.name.endsWith('.txt')) {
1026
+ continue;
1027
+ }
1028
+ try {
1029
+ const subEntries = await listDirectoryWithProbeTimeout(entry.cid);
1030
+ if (!subEntries) {
1031
+ continue;
1032
+ }
1033
+ const prefix = dirPath ? `${dirPath}/${entry.name}` : entry.name;
1034
+ const nestedThumbnail = await findThumbnailLookupInEntries(subEntries, prefix);
1035
+ if (nestedThumbnail) {
1036
+ return nestedThumbnail;
1037
+ }
1038
+ }
1039
+ catch {
1040
+ continue;
1041
+ }
1042
+ }
1043
+ }
444
1044
  return null;
445
- // List directory contents
446
- const entries = await tree.listDirectory(dirEntry.cid);
447
- if (!entries)
1045
+ }
1046
+ catch {
448
1047
  return null;
449
- // Find first matching thumbnail pattern
450
- for (const pattern of THUMBNAIL_PATTERNS) {
451
- if (entries.some(e => e.name === pattern)) {
452
- return dirPath ? `${dirPath}/${pattern}` : pattern;
453
- }
454
1048
  }
1049
+ }, (value) => getLookupTtlMs(value));
1050
+ }
1051
+ async function findPlayableLookupInDir(rootCid, dirPath) {
1052
+ if (!tree)
455
1053
  return null;
456
- }
457
- catch {
458
- return null;
459
- }
1054
+ return loadCachedLookup(resolvedEntryLookupCache, `${cidCacheKey(rootCid)}|video|${dirPath}`, async () => {
1055
+ try {
1056
+ const dirEntry = dirPath
1057
+ ? await resolvePathFromDirectoryListings(rootCid, dirPath)
1058
+ : { cid: rootCid, path: '' };
1059
+ if (!dirEntry)
1060
+ return null;
1061
+ const entries = await listDirectoryWithProbeTimeout(dirEntry.cid);
1062
+ if (entries && entries.length > 0) {
1063
+ const directPlayable = findPlayableLookupInEntries(entries, dirPath);
1064
+ if (directPlayable) {
1065
+ return directPlayable;
1066
+ }
1067
+ const sortedEntries = [...entries].sort((a, b) => a.name.localeCompare(b.name));
1068
+ for (const entry of sortedEntries.slice(0, 3)) {
1069
+ if (entry.name.endsWith('.json') || entry.name.endsWith('.txt') || !entry.cid) {
1070
+ continue;
1071
+ }
1072
+ const prefix = dirPath ? `${dirPath}/${entry.name}` : entry.name;
1073
+ const subEntries = await listDirectoryWithProbeTimeout(entry.cid);
1074
+ if (!subEntries || subEntries.length === 0) {
1075
+ const directPlayableChild = await detectDirectPlayableLookup(entry.cid, prefix);
1076
+ if (directPlayableChild) {
1077
+ return directPlayableChild;
1078
+ }
1079
+ continue;
1080
+ }
1081
+ const nestedPlayable = findPlayableLookupInEntries(subEntries, prefix);
1082
+ if (nestedPlayable) {
1083
+ return nestedPlayable;
1084
+ }
1085
+ }
1086
+ }
1087
+ return await detectDirectPlayableLookup(dirEntry.cid, dirPath);
1088
+ }
1089
+ catch {
1090
+ return null;
1091
+ }
1092
+ }, (value) => getLookupTtlMs(value));
460
1093
  }
461
1094
  /**
462
1095
  * Stream response to SW with proper HTTP headers
@@ -464,9 +1097,20 @@ async function findThumbnailInDir(rootCid, dirPath) {
464
1097
  async function streamSwResponse(requestId, cid, totalSize, options) {
465
1098
  if (!tree || !mediaPort)
466
1099
  return;
467
- const { npub, path, start = 0, end, mimeType = 'application/octet-stream', download } = options;
468
- const rangeStart = start;
469
- const rangeEnd = end !== undefined ? Math.min(end, totalSize - 1) : totalSize - 1;
1100
+ const { npub, path, start = 0, end, rangeHeader, mimeType = 'application/octet-stream', download } = options;
1101
+ let rangeStart = start;
1102
+ let rangeEnd = end !== undefined ? Math.min(end, totalSize - 1) : totalSize - 1;
1103
+ if (rangeHeader) {
1104
+ const parsedRange = parseHttpByteRange(rangeHeader, totalSize);
1105
+ if (parsedRange.kind === 'range') {
1106
+ rangeStart = parsedRange.range.start;
1107
+ rangeEnd = parsedRange.range.endInclusive;
1108
+ }
1109
+ else if (parsedRange.kind === 'unsatisfiable') {
1110
+ sendSwError(requestId, 416, `Range not satisfiable for ${totalSize} byte file`);
1111
+ return;
1112
+ }
1113
+ }
470
1114
  const contentLength = rangeEnd - rangeStart + 1;
471
1115
  // Build cache control header
472
1116
  const isNpubRequest = !!npub;
@@ -493,7 +1137,7 @@ async function streamSwResponse(requestId, cid, totalSize, options) {
493
1137
  headers['Content-Disposition'] = `attachment; filename="${filename}"`;
494
1138
  }
495
1139
  // Determine status (206 for range requests)
496
- const isRangeRequest = end !== undefined || start > 0;
1140
+ const isRangeRequest = !!rangeHeader || end !== undefined || start > 0;
497
1141
  const status = isRangeRequest ? 206 : 200;
498
1142
  if (isRangeRequest) {
499
1143
  headers['Content-Range'] = `bytes ${rangeStart}-${rangeEnd}/${totalSize}`;
@@ -526,4 +1170,96 @@ async function streamSwResponse(requestId, cid, totalSize, options) {
526
1170
  // Signal done
527
1171
  mediaPort.postMessage({ type: 'done', requestId });
528
1172
  }
1173
+ function sendBufferedSwResponse(requestId, data, options) {
1174
+ if (!mediaPort)
1175
+ return;
1176
+ const { npub, path, mimeType = 'application/octet-stream', download } = options;
1177
+ const isNpubRequest = !!npub;
1178
+ const isImage = mimeType.startsWith('image/');
1179
+ let cacheControl;
1180
+ if (!isNpubRequest) {
1181
+ cacheControl = 'public, max-age=31536000, immutable';
1182
+ }
1183
+ else if (isImage) {
1184
+ cacheControl = 'public, max-age=60, stale-while-revalidate=86400';
1185
+ }
1186
+ else {
1187
+ cacheControl = 'no-cache, no-store, must-revalidate';
1188
+ }
1189
+ const headers = {
1190
+ 'Content-Type': mimeType,
1191
+ 'Content-Length': String(data.length),
1192
+ 'Cache-Control': cacheControl,
1193
+ };
1194
+ if (download) {
1195
+ headers['Content-Disposition'] = `attachment; filename="${path || 'file'}"`;
1196
+ }
1197
+ mediaPort.postMessage({
1198
+ type: 'headers',
1199
+ requestId,
1200
+ status: 200,
1201
+ headers,
1202
+ totalSize: data.length,
1203
+ });
1204
+ mediaPort.postMessage({ type: 'chunk', requestId, data }, [data.buffer]);
1205
+ mediaPort.postMessage({ type: 'done', requestId });
1206
+ }
1207
+ function parseHttpByteRange(rangeHeader, totalSize) {
1208
+ if (!rangeHeader)
1209
+ return { kind: 'unsupported' };
1210
+ const bytesRange = rangeHeader.startsWith('bytes=')
1211
+ ? rangeHeader.slice('bytes='.length)
1212
+ : null;
1213
+ if (!bytesRange || bytesRange.includes(','))
1214
+ return { kind: 'unsupported' };
1215
+ if (totalSize <= 0)
1216
+ return { kind: 'unsatisfiable' };
1217
+ const parts = bytesRange.split('-', 2);
1218
+ if (parts.length !== 2)
1219
+ return { kind: 'unsupported' };
1220
+ const [startPart, endPart] = parts;
1221
+ if (!startPart) {
1222
+ const suffixLength = Number.parseInt(endPart, 10);
1223
+ if (!Number.isFinite(suffixLength) || suffixLength <= 0) {
1224
+ return { kind: 'unsatisfiable' };
1225
+ }
1226
+ const clampedSuffix = Math.min(suffixLength, totalSize);
1227
+ return {
1228
+ kind: 'range',
1229
+ range: {
1230
+ start: totalSize - clampedSuffix,
1231
+ endInclusive: totalSize - 1,
1232
+ },
1233
+ };
1234
+ }
1235
+ const start = Number.parseInt(startPart, 10);
1236
+ if (!Number.isFinite(start) || start < 0 || start >= totalSize) {
1237
+ return { kind: 'unsatisfiable' };
1238
+ }
1239
+ const endInclusive = endPart ? Number.parseInt(endPart, 10) : totalSize - 1;
1240
+ if (!Number.isFinite(endInclusive) || endInclusive < start) {
1241
+ return { kind: 'unsatisfiable' };
1242
+ }
1243
+ return {
1244
+ kind: 'range',
1245
+ range: {
1246
+ start,
1247
+ endInclusive: Math.min(endInclusive, totalSize - 1),
1248
+ },
1249
+ };
1250
+ }
1251
+ function isPlayableMediaFileName(name) {
1252
+ const normalized = name.trim().toLowerCase();
1253
+ if (!normalized || normalized.endsWith('/')) {
1254
+ return false;
1255
+ }
1256
+ if (normalized.startsWith('video.')) {
1257
+ return true;
1258
+ }
1259
+ const lastDot = normalized.lastIndexOf('.');
1260
+ if (lastDot === -1) {
1261
+ return false;
1262
+ }
1263
+ return PLAYABLE_MEDIA_EXTENSION_SET.has(normalized.slice(lastDot));
1264
+ }
529
1265
  //# sourceMappingURL=mediaHandler.js.map