@hashtree/worker 0.2.0 → 0.2.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.
- package/package.json +7 -3
- package/src/app-runtime.ts +393 -0
- package/src/capabilities/blossomBandwidthTracker.ts +74 -0
- package/src/capabilities/blossomTransport.ts +179 -0
- package/src/capabilities/connectivity.ts +54 -0
- package/src/capabilities/idbStorage.ts +94 -0
- package/src/capabilities/meshRouterStore.ts +426 -0
- package/src/capabilities/rootResolver.ts +497 -0
- package/src/client-id.ts +137 -0
- package/src/client.ts +501 -0
- package/src/entry.ts +3 -0
- package/src/htree-path.ts +53 -0
- package/src/htree-url.ts +156 -0
- package/src/index.ts +76 -0
- package/src/mediaStreaming.ts +64 -0
- package/src/p2p/boundedQueue.ts +168 -0
- package/src/p2p/errorMessage.ts +6 -0
- package/src/p2p/index.ts +48 -0
- package/src/p2p/lruCache.ts +78 -0
- package/src/p2p/meshQueryRouter.ts +361 -0
- package/src/p2p/protocol.ts +11 -0
- package/src/p2p/queryForwardingMachine.ts +197 -0
- package/src/p2p/signaling.ts +284 -0
- package/src/p2p/uploadRateLimiter.ts +85 -0
- package/src/p2p/webrtcController.ts +1168 -0
- package/src/p2p/webrtcProxy.ts +519 -0
- package/src/privacyGuards.ts +31 -0
- package/src/protocol.ts +124 -0
- package/src/relay/identity.ts +86 -0
- package/src/relay/mediaHandler.ts +1633 -0
- package/src/relay/ndk.ts +590 -0
- package/src/relay/nostr-wasm.ts +249 -0
- package/src/relay/nostr.ts +249 -0
- package/src/relay/protocol.ts +361 -0
- package/src/relay/publicAssetUrl.ts +25 -0
- package/src/relay/rootPathResolver.ts +50 -0
- package/src/relay/shims.d.ts +17 -0
- package/src/relay/signing.ts +332 -0
- package/src/relay/treeRootCache.ts +354 -0
- package/src/relay/treeRootSubscription.ts +577 -0
- package/src/relay/utils/constants.ts +139 -0
- package/src/relay/utils/errorMessage.ts +7 -0
- package/src/relay/utils/lruCache.ts +79 -0
- package/src/relay/webrtc.ts +5 -0
- package/src/relay/webrtcSignaling.ts +108 -0
- package/src/relay/worker.ts +1787 -0
- package/src/relay-client.ts +265 -0
- package/src/relay-entry.ts +1 -0
- package/src/runtime-network.ts +134 -0
- package/src/runtime.ts +153 -0
- package/src/transferableBytes.ts +5 -0
- package/src/tree-root.ts +851 -0
- package/src/types.ts +8 -0
- package/src/worker.ts +975 -0
|
@@ -0,0 +1,1633 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* Media Streaming Handler for Hashtree Worker
|
|
4
|
+
*
|
|
5
|
+
* Handles media requests from the service worker via MessagePort.
|
|
6
|
+
* Supports both direct CID-based requests and path-based requests with live streaming.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { HashTree, CID } from '@hashtree/core';
|
|
10
|
+
import type { MediaRequestByCid, MediaRequestByPath, MediaResponse } from './protocol';
|
|
11
|
+
import { getCachedRoot, onCachedRootUpdate } from './treeRootCache';
|
|
12
|
+
import { resolveTreeRootNow, subscribeToTreeRoots } from './treeRootSubscription';
|
|
13
|
+
import { getErrorMessage } from './utils/errorMessage';
|
|
14
|
+
import { nhashDecode, toHex } from '@hashtree/core';
|
|
15
|
+
import { readTreeEventSnapshot, resolveSnapshotRootCid } from '@hashtree/nostr';
|
|
16
|
+
import { nip19 } from 'nostr-tools';
|
|
17
|
+
import { LRUCache } from './utils/lruCache';
|
|
18
|
+
|
|
19
|
+
// Thumbnail filename patterns to look for (in priority order)
|
|
20
|
+
const THUMBNAIL_PATTERNS = ['thumbnail.jpg', 'thumbnail.webp', 'thumbnail.png', 'thumbnail.jpeg'];
|
|
21
|
+
const PLAYABLE_MEDIA_EXTENSION_SET = new Set([
|
|
22
|
+
'.mp4',
|
|
23
|
+
'.webm',
|
|
24
|
+
'.mkv',
|
|
25
|
+
'.mov',
|
|
26
|
+
'.avi',
|
|
27
|
+
'.m4v',
|
|
28
|
+
'.ogv',
|
|
29
|
+
'.3gp',
|
|
30
|
+
'.mp3',
|
|
31
|
+
'.wav',
|
|
32
|
+
'.flac',
|
|
33
|
+
'.m4a',
|
|
34
|
+
'.aac',
|
|
35
|
+
'.ogg',
|
|
36
|
+
'.oga',
|
|
37
|
+
'.opus',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const MIME_TYPES: Record<string, string> = {
|
|
41
|
+
mp4: 'video/mp4',
|
|
42
|
+
m4v: 'video/mp4',
|
|
43
|
+
webm: 'video/webm',
|
|
44
|
+
ogg: 'video/ogg',
|
|
45
|
+
ogv: 'video/ogg',
|
|
46
|
+
mov: 'video/quicktime',
|
|
47
|
+
avi: 'video/x-msvideo',
|
|
48
|
+
mkv: 'video/x-matroska',
|
|
49
|
+
mp3: 'audio/mpeg',
|
|
50
|
+
wav: 'audio/wav',
|
|
51
|
+
flac: 'audio/flac',
|
|
52
|
+
m4a: 'audio/mp4',
|
|
53
|
+
aac: 'audio/aac',
|
|
54
|
+
oga: 'audio/ogg',
|
|
55
|
+
opus: 'audio/ogg',
|
|
56
|
+
jpg: 'image/jpeg',
|
|
57
|
+
jpeg: 'image/jpeg',
|
|
58
|
+
png: 'image/png',
|
|
59
|
+
webp: 'image/webp',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* SW FileRequest format (from service worker)
|
|
64
|
+
*/
|
|
65
|
+
interface SwFileRequest {
|
|
66
|
+
type: 'hashtree-file';
|
|
67
|
+
requestId: string;
|
|
68
|
+
npub?: string;
|
|
69
|
+
nhash?: string;
|
|
70
|
+
snapshot?: boolean;
|
|
71
|
+
linkKey?: string | null;
|
|
72
|
+
treeName?: string;
|
|
73
|
+
path: string;
|
|
74
|
+
start: number;
|
|
75
|
+
end?: number;
|
|
76
|
+
rangeHeader?: string | null;
|
|
77
|
+
mimeType: string;
|
|
78
|
+
download?: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Extended response with HTTP headers for SW
|
|
83
|
+
*/
|
|
84
|
+
interface SwFileResponse {
|
|
85
|
+
type: 'headers' | 'chunk' | 'done' | 'error';
|
|
86
|
+
requestId: string;
|
|
87
|
+
status?: number;
|
|
88
|
+
headers?: Record<string, string>;
|
|
89
|
+
totalSize?: number;
|
|
90
|
+
data?: Uint8Array;
|
|
91
|
+
message?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface ResolvedRootEntry {
|
|
95
|
+
cid: CID;
|
|
96
|
+
size?: number;
|
|
97
|
+
path?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface ResolvedThumbnailLookup extends ResolvedRootEntry {
|
|
101
|
+
path: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface CachedLookupValue<T> {
|
|
105
|
+
value: T;
|
|
106
|
+
expiresAt: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface AsyncLookupCache<T> {
|
|
110
|
+
values: LRUCache<string, CachedLookupValue<T>>;
|
|
111
|
+
inflight: Map<string, Promise<T>>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Timeout for considering a stream "done" (no updates)
|
|
115
|
+
const LIVE_STREAM_TIMEOUT = 10000; // 10 seconds
|
|
116
|
+
const ROOT_WAIT_TIMEOUT_MS = 15000;
|
|
117
|
+
const DIRECTORY_PROBE_TIMEOUT_MS = 1000;
|
|
118
|
+
const IMMUTABLE_LOOKUP_CACHE_HIT_TTL_MS = 5 * 60 * 1000;
|
|
119
|
+
const IMMUTABLE_LOOKUP_CACHE_MISS_TTL_MS = 1000;
|
|
120
|
+
const DIRECTORY_CACHE_SIZE = 1024;
|
|
121
|
+
const RESOLVED_ENTRY_CACHE_SIZE = 2048;
|
|
122
|
+
const MUTABLE_RESOLVED_ENTRY_CACHE_SIZE = 2048;
|
|
123
|
+
const FILE_SIZE_CACHE_SIZE = 1024;
|
|
124
|
+
const THUMBNAIL_PATH_CACHE_SIZE = 1024;
|
|
125
|
+
|
|
126
|
+
// Chunk size for streaming to media port
|
|
127
|
+
const MEDIA_CHUNK_SIZE = 256 * 1024; // 256KB chunks - matches videoChunker's firstChunkSize
|
|
128
|
+
|
|
129
|
+
// Active media streams (for live streaming - can receive updates)
|
|
130
|
+
interface ActiveStream {
|
|
131
|
+
requestId: string;
|
|
132
|
+
npub: string;
|
|
133
|
+
path: string;
|
|
134
|
+
offset: number;
|
|
135
|
+
cancelled: boolean;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const activeMediaStreams = new Map<string, ActiveStream>();
|
|
139
|
+
const inflightRootWaits = new Map<string, Promise<CID | null>>();
|
|
140
|
+
const directoryLookupCache = createAsyncLookupCache(DIRECTORY_CACHE_SIZE);
|
|
141
|
+
const resolvedEntryLookupCache = createAsyncLookupCache(RESOLVED_ENTRY_CACHE_SIZE);
|
|
142
|
+
const mutableResolvedEntryLookupCache = createAsyncLookupCache(MUTABLE_RESOLVED_ENTRY_CACHE_SIZE);
|
|
143
|
+
const fileSizeLookupCache = createAsyncLookupCache(FILE_SIZE_CACHE_SIZE);
|
|
144
|
+
const thumbnailPathLookupCache = createAsyncLookupCache(THUMBNAIL_PATH_CACHE_SIZE);
|
|
145
|
+
|
|
146
|
+
let mediaPort: MessagePort | null = null;
|
|
147
|
+
let tree: HashTree | null = null;
|
|
148
|
+
let mediaDebugEnabled = false;
|
|
149
|
+
|
|
150
|
+
function logMediaDebug(event: string, data?: Record<string, unknown>): void {
|
|
151
|
+
if (!mediaDebugEnabled) return;
|
|
152
|
+
if (data) {
|
|
153
|
+
console.log(`[WorkerMedia] ${event}`, data);
|
|
154
|
+
} else {
|
|
155
|
+
console.log(`[WorkerMedia] ${event}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function createAsyncLookupCache<T>(maxEntries: number): AsyncLookupCache<T> {
|
|
160
|
+
return {
|
|
161
|
+
values: new LRUCache<string, CachedLookupValue<T>>(maxEntries),
|
|
162
|
+
inflight: new Map<string, Promise<T>>(),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function clearAsyncLookupCache<T>(cache: AsyncLookupCache<T>): void {
|
|
167
|
+
cache.values.clear();
|
|
168
|
+
cache.inflight.clear();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function clearMediaLookupCaches(): void {
|
|
172
|
+
inflightRootWaits.clear();
|
|
173
|
+
clearAsyncLookupCache(directoryLookupCache);
|
|
174
|
+
clearAsyncLookupCache(resolvedEntryLookupCache);
|
|
175
|
+
clearAsyncLookupCache(mutableResolvedEntryLookupCache);
|
|
176
|
+
clearAsyncLookupCache(fileSizeLookupCache);
|
|
177
|
+
clearAsyncLookupCache(thumbnailPathLookupCache);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function getLookupTtlMs<T>(value: T | null | undefined): number {
|
|
181
|
+
return value == null ? IMMUTABLE_LOOKUP_CACHE_MISS_TTL_MS : IMMUTABLE_LOOKUP_CACHE_HIT_TTL_MS;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function loadCachedLookup<T>(
|
|
185
|
+
cache: AsyncLookupCache<T>,
|
|
186
|
+
key: string,
|
|
187
|
+
loader: () => Promise<T>,
|
|
188
|
+
ttlMsForValue: (value: T) => number,
|
|
189
|
+
): Promise<T> {
|
|
190
|
+
const now = Date.now();
|
|
191
|
+
const cached = cache.values.get(key);
|
|
192
|
+
if (cached && cached.expiresAt > now) {
|
|
193
|
+
return cached.value;
|
|
194
|
+
}
|
|
195
|
+
if (cached) {
|
|
196
|
+
cache.values.delete(key);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const inflight = cache.inflight.get(key);
|
|
200
|
+
if (inflight) {
|
|
201
|
+
return inflight;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const pending = loader()
|
|
205
|
+
.then((value) => {
|
|
206
|
+
const ttlMs = ttlMsForValue(value);
|
|
207
|
+
if (ttlMs > 0) {
|
|
208
|
+
cache.values.set(key, {
|
|
209
|
+
value,
|
|
210
|
+
expiresAt: Date.now() + ttlMs,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return value;
|
|
214
|
+
})
|
|
215
|
+
.finally(() => {
|
|
216
|
+
cache.inflight.delete(key);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
cache.inflight.set(key, pending);
|
|
220
|
+
return pending;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function cidCacheKey(cid: CID): string {
|
|
224
|
+
return cid.key ? `${toHex(cid.hash)}?k=${toHex(cid.key)}` : toHex(cid.hash);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function sameCid(a: CID | null | undefined, b: CID | null | undefined): boolean {
|
|
228
|
+
if (!a && !b) return true;
|
|
229
|
+
if (!a || !b) return false;
|
|
230
|
+
return cidCacheKey(a) === cidCacheKey(b);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function resolveSnapshotRoot(nhash: string, linkKey?: string | null): Promise<CID | null> {
|
|
234
|
+
if (!tree) return null;
|
|
235
|
+
|
|
236
|
+
const snapshotCid = nhashDecode(nhash);
|
|
237
|
+
const snapshot = await readTreeEventSnapshot(tree, nip19, snapshotCid);
|
|
238
|
+
if (!snapshot) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return resolveSnapshotRootCid(snapshot, linkKey);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Initialize the media handler with the HashTree instance
|
|
247
|
+
*/
|
|
248
|
+
export function initMediaHandler(hashTree: HashTree): void {
|
|
249
|
+
tree = hashTree;
|
|
250
|
+
clearMediaLookupCaches();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Register a MessagePort from the service worker for media streaming
|
|
255
|
+
*/
|
|
256
|
+
export function registerMediaPort(port: MessagePort, debug?: boolean): void {
|
|
257
|
+
mediaPort = port;
|
|
258
|
+
mediaDebugEnabled = !!debug;
|
|
259
|
+
port.start?.();
|
|
260
|
+
|
|
261
|
+
port.onmessage = async (e: MessageEvent) => {
|
|
262
|
+
const req = e.data;
|
|
263
|
+
|
|
264
|
+
if (req.type === 'hashtree-file') {
|
|
265
|
+
// SW file request format (direct from service worker)
|
|
266
|
+
await handleSwFileRequest(req);
|
|
267
|
+
} else if (req.type === 'media') {
|
|
268
|
+
await handleMediaRequestByCid(req);
|
|
269
|
+
} else if (req.type === 'mediaByPath') {
|
|
270
|
+
await handleMediaRequestByPath(req);
|
|
271
|
+
} else if (req.type === 'cancelMedia') {
|
|
272
|
+
// Cancel an active stream
|
|
273
|
+
const stream = activeMediaStreams.get(req.requestId);
|
|
274
|
+
if (stream) {
|
|
275
|
+
stream.cancelled = true;
|
|
276
|
+
activeMediaStreams.delete(req.requestId);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
console.log('[Worker] Media port registered');
|
|
282
|
+
logMediaDebug('port:registered', { debug: mediaDebugEnabled });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Handle direct CID-based media request
|
|
287
|
+
*/
|
|
288
|
+
async function handleMediaRequestByCid(req: MediaRequestByCid): Promise<void> {
|
|
289
|
+
if (!tree || !mediaPort) return;
|
|
290
|
+
|
|
291
|
+
const { requestId, cid: cidHex, start, end, mimeType } = req;
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
// Convert hex CID to proper CID object
|
|
295
|
+
const hash = new Uint8Array(cidHex.length / 2);
|
|
296
|
+
for (let i = 0; i < hash.length; i++) {
|
|
297
|
+
hash[i] = parseInt(cidHex.substr(i * 2, 2), 16);
|
|
298
|
+
}
|
|
299
|
+
const cid = { hash };
|
|
300
|
+
|
|
301
|
+
// Get file size first
|
|
302
|
+
const totalSize = await tree.getSize(hash);
|
|
303
|
+
|
|
304
|
+
// Send headers
|
|
305
|
+
mediaPort.postMessage({
|
|
306
|
+
type: 'headers',
|
|
307
|
+
requestId,
|
|
308
|
+
totalSize,
|
|
309
|
+
mimeType: mimeType || 'application/octet-stream',
|
|
310
|
+
isLive: false,
|
|
311
|
+
} as MediaResponse);
|
|
312
|
+
|
|
313
|
+
// Read range and stream chunks
|
|
314
|
+
const data = await tree.readFileRange(cid, start, end);
|
|
315
|
+
if (data) {
|
|
316
|
+
await streamChunksToPort(requestId, data);
|
|
317
|
+
} else {
|
|
318
|
+
mediaPort.postMessage({
|
|
319
|
+
type: 'error',
|
|
320
|
+
requestId,
|
|
321
|
+
message: 'File not found',
|
|
322
|
+
} as MediaResponse);
|
|
323
|
+
}
|
|
324
|
+
} catch (err) {
|
|
325
|
+
mediaPort.postMessage({
|
|
326
|
+
type: 'error',
|
|
327
|
+
requestId,
|
|
328
|
+
message: getErrorMessage(err),
|
|
329
|
+
} as MediaResponse);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Handle npub/path-based media request (supports live streaming)
|
|
335
|
+
*/
|
|
336
|
+
async function handleMediaRequestByPath(req: MediaRequestByPath): Promise<void> {
|
|
337
|
+
if (!tree || !mediaPort) return;
|
|
338
|
+
|
|
339
|
+
const { requestId, npub, path, start, mimeType } = req;
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
// Parse path to get tree name
|
|
343
|
+
const pathParts = path.split('/').filter(Boolean);
|
|
344
|
+
const treeName = pathParts[0] || 'public';
|
|
345
|
+
const filePath = pathParts.slice(1).join('/');
|
|
346
|
+
|
|
347
|
+
// Resolve npub to current CID
|
|
348
|
+
let cid = await waitForCachedRoot(npub, treeName);
|
|
349
|
+
if (!cid) {
|
|
350
|
+
mediaPort.postMessage({
|
|
351
|
+
type: 'error',
|
|
352
|
+
requestId,
|
|
353
|
+
message: `Tree root not found for ${npub}/${treeName}`,
|
|
354
|
+
} as MediaResponse);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Navigate to file within tree if path specified
|
|
359
|
+
if (filePath) {
|
|
360
|
+
const resolved = await resolveMutableTreeEntry(npub, treeName, filePath, {
|
|
361
|
+
allowSingleSegmentRootFallback: false,
|
|
362
|
+
expectedMimeType: mimeType,
|
|
363
|
+
});
|
|
364
|
+
if (!resolved) {
|
|
365
|
+
mediaPort.postMessage({
|
|
366
|
+
type: 'error',
|
|
367
|
+
requestId,
|
|
368
|
+
message: `File not found: ${filePath}`,
|
|
369
|
+
} as MediaResponse);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
cid = resolved.cid;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Get file size
|
|
376
|
+
const totalSize = await tree.getSize(cid.hash);
|
|
377
|
+
|
|
378
|
+
// Send headers (isLive will be determined by watching for updates)
|
|
379
|
+
mediaPort.postMessage({
|
|
380
|
+
type: 'headers',
|
|
381
|
+
requestId,
|
|
382
|
+
totalSize,
|
|
383
|
+
mimeType: mimeType || 'application/octet-stream',
|
|
384
|
+
isLive: false, // Will update if we detect changes
|
|
385
|
+
} as MediaResponse);
|
|
386
|
+
|
|
387
|
+
// Stream initial content
|
|
388
|
+
const data = await tree.readFileRange(cid, start);
|
|
389
|
+
let offset = start;
|
|
390
|
+
|
|
391
|
+
if (data) {
|
|
392
|
+
await streamChunksToPort(requestId, data, false); // Don't close yet
|
|
393
|
+
offset += data.length;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Register for live updates
|
|
397
|
+
const streamInfo: ActiveStream = {
|
|
398
|
+
requestId,
|
|
399
|
+
npub,
|
|
400
|
+
path,
|
|
401
|
+
offset,
|
|
402
|
+
cancelled: false,
|
|
403
|
+
};
|
|
404
|
+
activeMediaStreams.set(requestId, streamInfo);
|
|
405
|
+
|
|
406
|
+
// Set up tree root watcher for this npub
|
|
407
|
+
// When root changes, we'll check if this file has new data
|
|
408
|
+
watchTreeRootForStream(npub, treeName, filePath, streamInfo);
|
|
409
|
+
} catch (err) {
|
|
410
|
+
mediaPort.postMessage({
|
|
411
|
+
type: 'error',
|
|
412
|
+
requestId,
|
|
413
|
+
message: getErrorMessage(err),
|
|
414
|
+
} as MediaResponse);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Stream data chunks to media port
|
|
420
|
+
*/
|
|
421
|
+
async function streamChunksToPort(
|
|
422
|
+
requestId: string,
|
|
423
|
+
data: Uint8Array,
|
|
424
|
+
sendDone = true
|
|
425
|
+
): Promise<void> {
|
|
426
|
+
if (!mediaPort) return;
|
|
427
|
+
|
|
428
|
+
for (let offset = 0; offset < data.length; offset += MEDIA_CHUNK_SIZE) {
|
|
429
|
+
const chunk = data.slice(offset, offset + MEDIA_CHUNK_SIZE);
|
|
430
|
+
mediaPort.postMessage(
|
|
431
|
+
{ type: 'chunk', requestId, data: chunk } as MediaResponse,
|
|
432
|
+
[chunk.buffer]
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (sendDone) {
|
|
437
|
+
mediaPort.postMessage({ type: 'done', requestId } as MediaResponse);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Watch for tree root updates and push new data to stream
|
|
443
|
+
*/
|
|
444
|
+
function watchTreeRootForStream(
|
|
445
|
+
npub: string,
|
|
446
|
+
treeName: string,
|
|
447
|
+
filePath: string,
|
|
448
|
+
streamInfo: ActiveStream
|
|
449
|
+
): void {
|
|
450
|
+
let lastActivity = Date.now();
|
|
451
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
452
|
+
|
|
453
|
+
const checkForUpdates = async () => {
|
|
454
|
+
if (streamInfo.cancelled || !tree || !mediaPort) {
|
|
455
|
+
cleanup();
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Check if stream timed out
|
|
460
|
+
if (Date.now() - lastActivity > LIVE_STREAM_TIMEOUT) {
|
|
461
|
+
// No updates for a while, close the stream
|
|
462
|
+
mediaPort.postMessage({
|
|
463
|
+
type: 'done',
|
|
464
|
+
requestId: streamInfo.requestId,
|
|
465
|
+
} as MediaResponse);
|
|
466
|
+
cleanup();
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
try {
|
|
471
|
+
// Get current root
|
|
472
|
+
const cid = await getCachedRoot(npub, treeName);
|
|
473
|
+
if (!cid) {
|
|
474
|
+
scheduleNext();
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Navigate to file
|
|
479
|
+
let fileCid: CID = cid;
|
|
480
|
+
if (filePath) {
|
|
481
|
+
const resolved = await resolvePathFromDirectoryListings(cid, filePath);
|
|
482
|
+
if (!resolved) {
|
|
483
|
+
scheduleNext();
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
fileCid = resolved.cid;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Check for new data
|
|
490
|
+
const totalSize = await tree.getSize(fileCid.hash);
|
|
491
|
+
if (totalSize > streamInfo.offset) {
|
|
492
|
+
// New data available!
|
|
493
|
+
lastActivity = Date.now();
|
|
494
|
+
const newData = await tree.readFileRange(fileCid, streamInfo.offset);
|
|
495
|
+
if (newData && newData.length > 0) {
|
|
496
|
+
await streamChunksToPort(streamInfo.requestId, newData, false);
|
|
497
|
+
streamInfo.offset += newData.length;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
} catch {
|
|
501
|
+
// Ignore errors, just try again
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
scheduleNext();
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
const scheduleNext = () => {
|
|
508
|
+
if (!streamInfo.cancelled) {
|
|
509
|
+
timeoutId = setTimeout(checkForUpdates, 1000); // Check every second
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
const cleanup = () => {
|
|
514
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
515
|
+
activeMediaStreams.delete(streamInfo.requestId);
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
// Start watching
|
|
519
|
+
scheduleNext();
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Handle file request from service worker (hashtree-file format)
|
|
524
|
+
* This is the main entry point for direct SW → Worker communication
|
|
525
|
+
*/
|
|
526
|
+
async function handleSwFileRequest(req: SwFileRequest): Promise<void> {
|
|
527
|
+
if (!tree || !mediaPort) return;
|
|
528
|
+
|
|
529
|
+
const { requestId, npub, nhash, snapshot, linkKey, treeName, path, start, end, rangeHeader, mimeType, download } = req;
|
|
530
|
+
logMediaDebug('sw:request', {
|
|
531
|
+
requestId,
|
|
532
|
+
npub: npub ?? null,
|
|
533
|
+
nhash: nhash ?? null,
|
|
534
|
+
snapshot: !!snapshot,
|
|
535
|
+
hasLinkKey: !!linkKey,
|
|
536
|
+
treeName: treeName ?? null,
|
|
537
|
+
path,
|
|
538
|
+
start,
|
|
539
|
+
end: end ?? null,
|
|
540
|
+
rangeHeader: rangeHeader ?? null,
|
|
541
|
+
mimeType,
|
|
542
|
+
download: !!download,
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
try {
|
|
546
|
+
let resolvedEntry: ResolvedRootEntry | null = null;
|
|
547
|
+
|
|
548
|
+
if (nhash) {
|
|
549
|
+
// Direct nhash request - decode to CID or resolve signed snapshot permalink.
|
|
550
|
+
const rootCid = snapshot
|
|
551
|
+
? await resolveSnapshotRoot(nhash, linkKey)
|
|
552
|
+
: nhashDecode(nhash);
|
|
553
|
+
if (!rootCid) {
|
|
554
|
+
sendSwError(requestId, snapshot ? 403 : 404, snapshot ? 'Tree snapshot could not be resolved' : `File not found: ${path}`);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
resolvedEntry = await resolveEntryWithinRoot(rootCid, path || '', {
|
|
558
|
+
allowSingleSegmentRootFallback: true,
|
|
559
|
+
expectedMimeType: mimeType,
|
|
560
|
+
});
|
|
561
|
+
if (!resolvedEntry) {
|
|
562
|
+
sendSwError(requestId, 404, `File not found: ${path}`);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
} else if (npub && treeName) {
|
|
566
|
+
// Npub-based request - resolve through mutable root history when needed
|
|
567
|
+
resolvedEntry = await resolveMutableTreeEntry(npub, treeName, path || '', {
|
|
568
|
+
allowSingleSegmentRootFallback: false,
|
|
569
|
+
expectedMimeType: mimeType,
|
|
570
|
+
});
|
|
571
|
+
if (!resolvedEntry) {
|
|
572
|
+
sendSwError(requestId, 404, 'File not found');
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (!resolvedEntry?.cid) {
|
|
578
|
+
sendSwError(requestId, 400, 'Invalid request');
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Get file size
|
|
583
|
+
// Directory listings may report 0 for non-empty files when the actual byte size
|
|
584
|
+
// is not embedded in the tree node metadata. Treat that as unknown and resolve
|
|
585
|
+
// the real size from the file itself before building HTTP headers.
|
|
586
|
+
const knownSize = typeof resolvedEntry.size === 'number' && resolvedEntry.size > 0
|
|
587
|
+
? resolvedEntry.size
|
|
588
|
+
: null;
|
|
589
|
+
const effectivePath = resolvedEntry.path ?? path;
|
|
590
|
+
const effectiveMimeType = (
|
|
591
|
+
mimeType === 'application/octet-stream'
|
|
592
|
+
|| isThumbnailAliasPath(path)
|
|
593
|
+
|| isVideoAliasPath(path)
|
|
594
|
+
)
|
|
595
|
+
? guessMimeTypeFromPath(effectivePath)
|
|
596
|
+
: mimeType;
|
|
597
|
+
|
|
598
|
+
const totalSize = knownSize ?? await getFileSize(resolvedEntry.cid);
|
|
599
|
+
if (totalSize === null) {
|
|
600
|
+
const canBufferWholeFile = !rangeHeader && start === 0 && end === undefined;
|
|
601
|
+
if (!canBufferWholeFile || typeof tree.readFile !== 'function') {
|
|
602
|
+
sendSwError(requestId, 404, 'File data not found');
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
const fullData = await tree.readFile(resolvedEntry.cid);
|
|
606
|
+
if (!fullData) {
|
|
607
|
+
sendSwError(requestId, 404, 'File data not found');
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
sendBufferedSwResponse(requestId, fullData, {
|
|
611
|
+
npub,
|
|
612
|
+
path: effectivePath,
|
|
613
|
+
mimeType: effectiveMimeType,
|
|
614
|
+
download,
|
|
615
|
+
});
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Stream the content
|
|
620
|
+
await streamSwResponse(requestId, resolvedEntry.cid, totalSize, {
|
|
621
|
+
npub,
|
|
622
|
+
path: effectivePath,
|
|
623
|
+
start,
|
|
624
|
+
end,
|
|
625
|
+
rangeHeader,
|
|
626
|
+
mimeType: effectiveMimeType,
|
|
627
|
+
download,
|
|
628
|
+
});
|
|
629
|
+
} catch (err) {
|
|
630
|
+
sendSwError(requestId, 500, getErrorMessage(err));
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function waitForCachedRoot(npub: string, treeName: string): Promise<CID | null> {
|
|
635
|
+
const cached = await getCachedRoot(npub, treeName);
|
|
636
|
+
if (cached) return cached;
|
|
637
|
+
|
|
638
|
+
const cacheKey = `${npub}/${treeName}`;
|
|
639
|
+
const inflight = inflightRootWaits.get(cacheKey);
|
|
640
|
+
if (inflight) {
|
|
641
|
+
return inflight;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const pubkey = decodeNpubToPubkey(npub);
|
|
645
|
+
if (pubkey) {
|
|
646
|
+
subscribeToTreeRoots(pubkey);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const pending = new Promise<CID | null>((resolve) => {
|
|
650
|
+
let settled = false;
|
|
651
|
+
const finish = (cid: CID | null) => {
|
|
652
|
+
if (settled) return;
|
|
653
|
+
settled = true;
|
|
654
|
+
clearTimeout(timeout);
|
|
655
|
+
unsubscribe();
|
|
656
|
+
resolve(cid);
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
const unsubscribe = onCachedRootUpdate((updatedNpub, updatedTreeName, cid) => {
|
|
660
|
+
if (updatedNpub === npub && updatedTreeName === treeName && cid) {
|
|
661
|
+
finish(cid);
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
const timeout = setTimeout(() => {
|
|
666
|
+
logMediaDebug('root:timeout', { npub, treeName });
|
|
667
|
+
finish(null);
|
|
668
|
+
}, ROOT_WAIT_TIMEOUT_MS);
|
|
669
|
+
|
|
670
|
+
void getCachedRoot(npub, treeName).then((current) => {
|
|
671
|
+
if (current) {
|
|
672
|
+
finish(current);
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
void resolveTreeRootNow(npub, treeName, ROOT_WAIT_TIMEOUT_MS).then((resolved) => {
|
|
676
|
+
if (resolved) {
|
|
677
|
+
finish(resolved);
|
|
678
|
+
}
|
|
679
|
+
}).catch(() => {});
|
|
680
|
+
}).finally(() => {
|
|
681
|
+
inflightRootWaits.delete(cacheKey);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
inflightRootWaits.set(cacheKey, pending);
|
|
685
|
+
return pending;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
async function resolveMutableTreeEntry(
|
|
689
|
+
npub: string,
|
|
690
|
+
treeName: string,
|
|
691
|
+
path: string,
|
|
692
|
+
options?: { allowSingleSegmentRootFallback?: boolean; expectedMimeType?: string },
|
|
693
|
+
): Promise<ResolvedRootEntry | null> {
|
|
694
|
+
const currentRoot = await waitForCachedRoot(npub, treeName);
|
|
695
|
+
const cacheKey = [
|
|
696
|
+
npub,
|
|
697
|
+
treeName,
|
|
698
|
+
currentRoot ? cidCacheKey(currentRoot) : 'none',
|
|
699
|
+
options?.allowSingleSegmentRootFallback ? 'root-fallback' : 'strict',
|
|
700
|
+
options?.expectedMimeType ?? '',
|
|
701
|
+
path,
|
|
702
|
+
].join('|');
|
|
703
|
+
|
|
704
|
+
return loadCachedLookup(
|
|
705
|
+
mutableResolvedEntryLookupCache,
|
|
706
|
+
cacheKey,
|
|
707
|
+
async () => {
|
|
708
|
+
if (currentRoot) {
|
|
709
|
+
const currentEntry = await resolveEntryWithinRoot(currentRoot, path, options);
|
|
710
|
+
if (currentEntry) {
|
|
711
|
+
return currentEntry;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
return null;
|
|
716
|
+
},
|
|
717
|
+
(value) => getLookupTtlMs(value),
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function decodeNpubToPubkey(npub: string): string | null {
|
|
722
|
+
if (!npub.startsWith('npub1')) return null;
|
|
723
|
+
try {
|
|
724
|
+
const decoded = nip19.decode(npub);
|
|
725
|
+
if (decoded.type !== 'npub') return null;
|
|
726
|
+
return decoded.data as string;
|
|
727
|
+
} catch {
|
|
728
|
+
return null;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
async function resolveEntryWithinRoot(
|
|
733
|
+
rootCid: CID,
|
|
734
|
+
path: string,
|
|
735
|
+
options?: { allowSingleSegmentRootFallback?: boolean; expectedMimeType?: string }
|
|
736
|
+
): Promise<ResolvedRootEntry | null> {
|
|
737
|
+
if (!tree) return null;
|
|
738
|
+
|
|
739
|
+
const cacheKey = [
|
|
740
|
+
cidCacheKey(rootCid),
|
|
741
|
+
options?.allowSingleSegmentRootFallback ? 'root-fallback' : 'strict',
|
|
742
|
+
path,
|
|
743
|
+
].join('|');
|
|
744
|
+
|
|
745
|
+
return loadCachedLookup(
|
|
746
|
+
resolvedEntryLookupCache,
|
|
747
|
+
cacheKey,
|
|
748
|
+
async () => {
|
|
749
|
+
if (!path) {
|
|
750
|
+
return { cid: rootCid };
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const resolvedThumbnail = await resolveThumbnailAliasEntry(rootCid, path);
|
|
754
|
+
if (resolvedThumbnail) {
|
|
755
|
+
return resolvedThumbnail;
|
|
756
|
+
}
|
|
757
|
+
if (isThumbnailAliasPath(path)) {
|
|
758
|
+
return null;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const resolvedPlayable = await resolvePlayableAliasEntry(rootCid, path);
|
|
762
|
+
if (resolvedPlayable) {
|
|
763
|
+
return resolvedPlayable;
|
|
764
|
+
}
|
|
765
|
+
if (isVideoAliasPath(path)) {
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (
|
|
770
|
+
options?.allowSingleSegmentRootFallback &&
|
|
771
|
+
await canFallbackToRootBlob(rootCid, path, path, options?.expectedMimeType)
|
|
772
|
+
) {
|
|
773
|
+
const isDirectory = await canListDirectory(rootCid);
|
|
774
|
+
if (!isDirectory) {
|
|
775
|
+
return { cid: rootCid };
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (
|
|
780
|
+
options?.allowSingleSegmentRootFallback &&
|
|
781
|
+
!path.includes('/') &&
|
|
782
|
+
isExactThumbnailFilenamePath(path) &&
|
|
783
|
+
!(await canListDirectory(rootCid))
|
|
784
|
+
) {
|
|
785
|
+
return null;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const entry = await resolvePathFromDirectoryListings(rootCid, path);
|
|
789
|
+
if (entry) {
|
|
790
|
+
return entry;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
if (
|
|
794
|
+
options?.allowSingleSegmentRootFallback &&
|
|
795
|
+
await canFallbackToRootBlob(rootCid, path, path, options?.expectedMimeType)
|
|
796
|
+
) {
|
|
797
|
+
return { cid: rootCid };
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
return null;
|
|
801
|
+
},
|
|
802
|
+
(value) => getLookupTtlMs(value),
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
async function resolveCidWithinRoot(
|
|
807
|
+
rootCid: CID,
|
|
808
|
+
path: string,
|
|
809
|
+
options?: { allowSingleSegmentRootFallback?: boolean; expectedMimeType?: string }
|
|
810
|
+
): Promise<CID | null> {
|
|
811
|
+
return (await resolveEntryWithinRoot(rootCid, path, options))?.cid ?? null;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
async function resolvePathFromDirectoryListings(
|
|
815
|
+
rootCid: CID,
|
|
816
|
+
path: string
|
|
817
|
+
): Promise<ResolvedRootEntry | null> {
|
|
818
|
+
if (!tree) return null;
|
|
819
|
+
|
|
820
|
+
const parts = path.split('/').filter(Boolean);
|
|
821
|
+
if (parts.length === 0) {
|
|
822
|
+
return { cid: rootCid };
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
let currentCid = rootCid;
|
|
826
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
827
|
+
const entries = await loadDirectoryListing(currentCid);
|
|
828
|
+
if (!entries) {
|
|
829
|
+
return null;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const entry = entries.find((candidate) => candidate.name === parts[i]);
|
|
833
|
+
if (!entry?.cid) {
|
|
834
|
+
return null;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (i === parts.length - 1) {
|
|
838
|
+
return { cid: entry.cid, size: entry.size, path: parts.slice(0, i + 1).join('/') };
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
currentCid = entry.cid;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
return null;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function hasImageBlobSignature(blob: Uint8Array): boolean {
|
|
848
|
+
if (blob.length >= 3 && blob[0] === 0xff && blob[1] === 0xd8 && blob[2] === 0xff) {
|
|
849
|
+
return true;
|
|
850
|
+
}
|
|
851
|
+
if (
|
|
852
|
+
blob.length >= 8
|
|
853
|
+
&& blob[0] === 0x89
|
|
854
|
+
&& blob[1] === 0x50
|
|
855
|
+
&& blob[2] === 0x4e
|
|
856
|
+
&& blob[3] === 0x47
|
|
857
|
+
&& blob[4] === 0x0d
|
|
858
|
+
&& blob[5] === 0x0a
|
|
859
|
+
&& blob[6] === 0x1a
|
|
860
|
+
&& blob[7] === 0x0a
|
|
861
|
+
) {
|
|
862
|
+
return true;
|
|
863
|
+
}
|
|
864
|
+
if (
|
|
865
|
+
blob.length >= 12
|
|
866
|
+
&& blob[0] === 0x52
|
|
867
|
+
&& blob[1] === 0x49
|
|
868
|
+
&& blob[2] === 0x46
|
|
869
|
+
&& blob[3] === 0x46
|
|
870
|
+
&& blob[8] === 0x57
|
|
871
|
+
&& blob[9] === 0x45
|
|
872
|
+
&& blob[10] === 0x42
|
|
873
|
+
&& blob[11] === 0x50
|
|
874
|
+
) {
|
|
875
|
+
return true;
|
|
876
|
+
}
|
|
877
|
+
if (
|
|
878
|
+
blob.length >= 6
|
|
879
|
+
&& blob[0] === 0x47
|
|
880
|
+
&& blob[1] === 0x49
|
|
881
|
+
&& blob[2] === 0x46
|
|
882
|
+
&& blob[3] === 0x38
|
|
883
|
+
&& (blob[4] === 0x37 || blob[4] === 0x39)
|
|
884
|
+
&& blob[5] === 0x61
|
|
885
|
+
) {
|
|
886
|
+
return true;
|
|
887
|
+
}
|
|
888
|
+
return false;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function readAscii(blob: Uint8Array, start: number, end: number): string {
|
|
892
|
+
return String.fromCharCode(...blob.slice(start, end));
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function sniffPlayableMediaExtension(blob: Uint8Array): string | null {
|
|
896
|
+
if (!blob.length) return null;
|
|
897
|
+
|
|
898
|
+
if (blob.length >= 12 && readAscii(blob, 4, 8) === 'ftyp') {
|
|
899
|
+
const brand = readAscii(blob, 8, 12).toLowerCase();
|
|
900
|
+
if (brand.startsWith('m4a')) return '.m4a';
|
|
901
|
+
if (brand.startsWith('qt')) return '.mov';
|
|
902
|
+
return '.mp4';
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (
|
|
906
|
+
blob.length >= 4
|
|
907
|
+
&& blob[0] === 0x1a
|
|
908
|
+
&& blob[1] === 0x45
|
|
909
|
+
&& blob[2] === 0xdf
|
|
910
|
+
&& blob[3] === 0xa3
|
|
911
|
+
) {
|
|
912
|
+
const lowerHeader = readAscii(blob, 0, Math.min(blob.length, 64)).toLowerCase();
|
|
913
|
+
return lowerHeader.includes('webm') ? '.webm' : '.mkv';
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
if (blob.length >= 4 && readAscii(blob, 0, 4) === 'OggS') {
|
|
917
|
+
return '.ogg';
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
if (blob.length >= 4 && readAscii(blob, 0, 4) === 'fLaC') {
|
|
921
|
+
return '.flac';
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
if (
|
|
925
|
+
blob.length >= 12
|
|
926
|
+
&& readAscii(blob, 0, 4) === 'RIFF'
|
|
927
|
+
&& readAscii(blob, 8, 12) === 'WAVE'
|
|
928
|
+
) {
|
|
929
|
+
return '.wav';
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
if (blob.length >= 3 && readAscii(blob, 0, 3) === 'ID3') {
|
|
933
|
+
return '.mp3';
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
if (blob.length >= 2 && blob[0] === 0xff && (blob[1] & 0xf6) === 0xf0) {
|
|
937
|
+
return '.aac';
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
if (blob.length >= 2 && blob[0] === 0xff && (blob[1] & 0xe0) === 0xe0) {
|
|
941
|
+
return '.mp3';
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
return null;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function guessMimeTypeFromPath(path: string | undefined): string {
|
|
948
|
+
const ext = path?.split('.').pop()?.toLowerCase() ?? '';
|
|
949
|
+
return MIME_TYPES[ext] || 'application/octet-stream';
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
async function canFallbackToRootBlob(
|
|
953
|
+
rootCid: CID,
|
|
954
|
+
resolvedPath: string,
|
|
955
|
+
originalPath: string,
|
|
956
|
+
expectedMimeType?: string,
|
|
957
|
+
): Promise<boolean> {
|
|
958
|
+
if (resolvedPath !== originalPath) return false;
|
|
959
|
+
if (resolvedPath.includes('/')) return false;
|
|
960
|
+
if (isExactThumbnailFilenamePath(resolvedPath)) {
|
|
961
|
+
if (!expectedMimeType?.startsWith('image/')) {
|
|
962
|
+
return false;
|
|
963
|
+
}
|
|
964
|
+
if (!tree) {
|
|
965
|
+
return false;
|
|
966
|
+
}
|
|
967
|
+
try {
|
|
968
|
+
if (typeof tree.readFileRange === 'function') {
|
|
969
|
+
const header = await tree.readFileRange(rootCid, 0, 64);
|
|
970
|
+
return !!header && hasImageBlobSignature(header);
|
|
971
|
+
}
|
|
972
|
+
if (typeof tree.getBlob === 'function') {
|
|
973
|
+
const blob = await tree.getBlob(rootCid.hash);
|
|
974
|
+
return !!blob && hasImageBlobSignature(blob);
|
|
975
|
+
}
|
|
976
|
+
return false;
|
|
977
|
+
} catch {
|
|
978
|
+
return false;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
return /\.[A-Za-z0-9]{1,16}$/.test(resolvedPath);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function isThumbnailAliasPath(path: string): boolean {
|
|
985
|
+
return path === 'thumbnail' || path.endsWith('/thumbnail');
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function isVideoAliasPath(path: string): boolean {
|
|
989
|
+
return path === 'video' || path.endsWith('/video');
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function isExactThumbnailFilenamePath(path: string): boolean {
|
|
993
|
+
const fileName = path.split('/').filter(Boolean).at(-1)?.toLowerCase() ?? '';
|
|
994
|
+
return fileName.startsWith('thumbnail.');
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function isImageFileName(fileName: string): boolean {
|
|
998
|
+
const normalized = fileName.trim().toLowerCase();
|
|
999
|
+
return normalized.endsWith('.jpg')
|
|
1000
|
+
|| normalized.endsWith('.jpeg')
|
|
1001
|
+
|| normalized.endsWith('.png')
|
|
1002
|
+
|| normalized.endsWith('.webp');
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function findThumbnailFileEntry(
|
|
1006
|
+
entries: Array<{ name: string; cid?: CID; size?: number }>
|
|
1007
|
+
): { name: string; cid?: CID; size?: number } | null {
|
|
1008
|
+
for (const pattern of THUMBNAIL_PATTERNS) {
|
|
1009
|
+
const directMatch = entries.find((entry) => entry.name === pattern && entry.cid);
|
|
1010
|
+
if (directMatch?.cid) {
|
|
1011
|
+
return directMatch;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
return entries.find((entry) => isImageFileName(entry.name) && entry.cid) ?? null;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function resolveEmbeddedThumbnailLookup(
|
|
1019
|
+
value: unknown,
|
|
1020
|
+
entries: Array<{ name: string; cid?: CID; size?: number }>,
|
|
1021
|
+
dirPath: string,
|
|
1022
|
+
): ResolvedThumbnailLookup | null {
|
|
1023
|
+
if (typeof value !== 'string') {
|
|
1024
|
+
return null;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
const trimmed = value.trim();
|
|
1028
|
+
if (!trimmed) {
|
|
1029
|
+
return null;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
if (trimmed.startsWith('nhash1')) {
|
|
1033
|
+
try {
|
|
1034
|
+
return {
|
|
1035
|
+
path: dirPath ? `${dirPath}/thumbnail` : 'thumbnail',
|
|
1036
|
+
cid: nhashDecode(trimmed),
|
|
1037
|
+
};
|
|
1038
|
+
} catch {
|
|
1039
|
+
return null;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const normalized = trimmed
|
|
1044
|
+
.split('?')[0]
|
|
1045
|
+
?.split('#')[0]
|
|
1046
|
+
?.split('/')
|
|
1047
|
+
.filter(Boolean)
|
|
1048
|
+
.at(-1);
|
|
1049
|
+
if (!normalized) {
|
|
1050
|
+
return null;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const entry = entries.find((candidate) => candidate.name === normalized && candidate.cid);
|
|
1054
|
+
if (!entry?.cid) {
|
|
1055
|
+
return null;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
return {
|
|
1059
|
+
path: dirPath ? `${dirPath}/${entry.name}` : entry.name,
|
|
1060
|
+
cid: entry.cid,
|
|
1061
|
+
size: entry.size,
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
async function resolveThumbnailLookupFromMetadata(
|
|
1066
|
+
entries: Array<{ name: string; cid?: CID; size?: number; meta?: Record<string, unknown> }>,
|
|
1067
|
+
dirPath: string,
|
|
1068
|
+
): Promise<ResolvedThumbnailLookup | null> {
|
|
1069
|
+
if (!tree) {
|
|
1070
|
+
return null;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
const playableEntry = entries.find((entry) => isPlayableMediaFileName(entry.name));
|
|
1074
|
+
const playableThumbnail = playableEntry?.meta && typeof playableEntry.meta.thumbnail === 'string'
|
|
1075
|
+
? playableEntry.meta.thumbnail
|
|
1076
|
+
: null;
|
|
1077
|
+
const embeddedPlayableThumbnail = resolveEmbeddedThumbnailLookup(playableThumbnail, entries, dirPath);
|
|
1078
|
+
if (embeddedPlayableThumbnail) {
|
|
1079
|
+
return embeddedPlayableThumbnail;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
for (const metadataName of ['metadata.json', 'info.json']) {
|
|
1083
|
+
const metadataEntry = entries.find((entry) => entry.name === metadataName && entry.cid);
|
|
1084
|
+
if (!metadataEntry?.cid) {
|
|
1085
|
+
continue;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
try {
|
|
1089
|
+
const metadataData = await tree.readFile(metadataEntry.cid);
|
|
1090
|
+
if (!metadataData) {
|
|
1091
|
+
continue;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
const parsed = JSON.parse(new TextDecoder().decode(metadataData));
|
|
1095
|
+
const embeddedThumbnail = resolveEmbeddedThumbnailLookup(parsed?.thumbnail, entries, dirPath);
|
|
1096
|
+
if (embeddedThumbnail) {
|
|
1097
|
+
return embeddedThumbnail;
|
|
1098
|
+
}
|
|
1099
|
+
} catch {
|
|
1100
|
+
continue;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
return null;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
async function findThumbnailLookupInEntries(
|
|
1108
|
+
entries: Array<{ name: string; cid?: CID; size?: number; meta?: Record<string, unknown> }>,
|
|
1109
|
+
dirPath: string,
|
|
1110
|
+
): Promise<ResolvedThumbnailLookup | null> {
|
|
1111
|
+
const directThumbnail = findThumbnailFileEntry(entries);
|
|
1112
|
+
if (directThumbnail?.cid) {
|
|
1113
|
+
return {
|
|
1114
|
+
path: dirPath ? `${dirPath}/${directThumbnail.name}` : directThumbnail.name,
|
|
1115
|
+
cid: directThumbnail.cid,
|
|
1116
|
+
size: directThumbnail.size,
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
return await resolveThumbnailLookupFromMetadata(entries, dirPath);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
async function detectDirectPlayableLookup(
|
|
1124
|
+
cid: CID,
|
|
1125
|
+
dirPath: string,
|
|
1126
|
+
): Promise<ResolvedRootEntry | null> {
|
|
1127
|
+
if (!tree || typeof tree.readFileRange !== 'function') {
|
|
1128
|
+
return null;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
try {
|
|
1132
|
+
const header = await tree.readFileRange(cid, 0, 64);
|
|
1133
|
+
if (!(header instanceof Uint8Array) || header.length === 0) {
|
|
1134
|
+
return null;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const extension = sniffPlayableMediaExtension(header);
|
|
1138
|
+
if (!extension) {
|
|
1139
|
+
return null;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
const fileName = `video${extension}`;
|
|
1143
|
+
return {
|
|
1144
|
+
cid,
|
|
1145
|
+
path: dirPath ? `${dirPath}/${fileName}` : fileName,
|
|
1146
|
+
};
|
|
1147
|
+
} catch {
|
|
1148
|
+
return null;
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function findPlayableLookupInEntries(
|
|
1153
|
+
entries: Array<{ name: string; cid?: CID; size?: number }>,
|
|
1154
|
+
dirPath: string,
|
|
1155
|
+
): ResolvedRootEntry | null {
|
|
1156
|
+
const playableEntry = entries.find((entry) => isPlayableMediaFileName(entry.name) && entry.cid);
|
|
1157
|
+
if (!playableEntry?.cid) {
|
|
1158
|
+
return null;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
return {
|
|
1162
|
+
cid: playableEntry.cid,
|
|
1163
|
+
size: playableEntry.size,
|
|
1164
|
+
path: dirPath ? `${dirPath}/${playableEntry.name}` : playableEntry.name,
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
async function normalizeAliasPath(rootCid: CID, path: string): Promise<string> {
|
|
1169
|
+
if (!path) return '';
|
|
1170
|
+
const resolvedThumbnail = await resolveThumbnailAliasEntry(rootCid, path);
|
|
1171
|
+
if (resolvedThumbnail) {
|
|
1172
|
+
return resolvedThumbnail.path;
|
|
1173
|
+
}
|
|
1174
|
+
const resolvedPlayable = await resolvePlayableAliasEntry(rootCid, path);
|
|
1175
|
+
if (resolvedPlayable?.path) {
|
|
1176
|
+
return resolvedPlayable.path;
|
|
1177
|
+
}
|
|
1178
|
+
return path;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
async function canListDirectory(rootCid: CID): Promise<boolean> {
|
|
1182
|
+
if (!tree) return false;
|
|
1183
|
+
try {
|
|
1184
|
+
if ('isDirectory' in tree && typeof tree.isDirectory === 'function') {
|
|
1185
|
+
return await tree.isDirectory(rootCid);
|
|
1186
|
+
}
|
|
1187
|
+
const entries = await listDirectoryWithProbeTimeout(rootCid);
|
|
1188
|
+
return Array.isArray(entries);
|
|
1189
|
+
} catch {
|
|
1190
|
+
return false;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
export const __test__ = {
|
|
1195
|
+
handleSwFileRequest,
|
|
1196
|
+
resolveCidWithinRoot,
|
|
1197
|
+
resolveMutableTreeEntry,
|
|
1198
|
+
normalizeAliasPath,
|
|
1199
|
+
canListDirectory,
|
|
1200
|
+
waitForCachedRoot,
|
|
1201
|
+
};
|
|
1202
|
+
|
|
1203
|
+
async function resolveThumbnailAliasEntry(
|
|
1204
|
+
rootCid: CID,
|
|
1205
|
+
path: string
|
|
1206
|
+
): Promise<ResolvedThumbnailLookup | null> {
|
|
1207
|
+
if (!isThumbnailAliasPath(path)) {
|
|
1208
|
+
return null;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
const dirPath = path.endsWith('/thumbnail')
|
|
1212
|
+
? path.slice(0, -'/thumbnail'.length)
|
|
1213
|
+
: '';
|
|
1214
|
+
return findThumbnailLookupInDir(rootCid, dirPath);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
async function resolvePlayableAliasEntry(
|
|
1218
|
+
rootCid: CID,
|
|
1219
|
+
path: string
|
|
1220
|
+
): Promise<ResolvedRootEntry | null> {
|
|
1221
|
+
if (!isVideoAliasPath(path)) {
|
|
1222
|
+
return null;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
const dirPath = path.endsWith('/video')
|
|
1226
|
+
? path.slice(0, -'/video'.length)
|
|
1227
|
+
: '';
|
|
1228
|
+
return findPlayableLookupInDir(rootCid, dirPath);
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
async function loadDirectoryListing(cid: CID): Promise<Awaited<ReturnType<HashTree['listDirectory']>> | null> {
|
|
1232
|
+
if (!tree) return null;
|
|
1233
|
+
return loadCachedLookup(
|
|
1234
|
+
directoryLookupCache,
|
|
1235
|
+
cidCacheKey(cid),
|
|
1236
|
+
async () => tree.listDirectory(cid),
|
|
1237
|
+
(value) => getLookupTtlMs(value),
|
|
1238
|
+
);
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
async function listDirectoryWithProbeTimeout(cid: CID): Promise<Awaited<ReturnType<HashTree['listDirectory']>> | null> {
|
|
1242
|
+
if (!tree) return null;
|
|
1243
|
+
return Promise.race([
|
|
1244
|
+
loadDirectoryListing(cid),
|
|
1245
|
+
new Promise<null>((resolve) => setTimeout(() => resolve(null), DIRECTORY_PROBE_TIMEOUT_MS)),
|
|
1246
|
+
]);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/**
|
|
1250
|
+
* Send error response to SW
|
|
1251
|
+
*/
|
|
1252
|
+
function sendSwError(requestId: string, status: number, message: string): void {
|
|
1253
|
+
if (!mediaPort) return;
|
|
1254
|
+
logMediaDebug('sw:error', { requestId, status, message });
|
|
1255
|
+
mediaPort.postMessage({
|
|
1256
|
+
type: 'error',
|
|
1257
|
+
requestId,
|
|
1258
|
+
status,
|
|
1259
|
+
message,
|
|
1260
|
+
} as SwFileResponse);
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* Get file size from CID (handles both chunked and single blob files)
|
|
1265
|
+
*/
|
|
1266
|
+
async function getFileSize(cid: CID): Promise<number | null> {
|
|
1267
|
+
if (!tree) return null;
|
|
1268
|
+
|
|
1269
|
+
return loadCachedLookup(
|
|
1270
|
+
fileSizeLookupCache,
|
|
1271
|
+
cidCacheKey(cid),
|
|
1272
|
+
async () => {
|
|
1273
|
+
const treeNode = await tree.getTreeNode(cid);
|
|
1274
|
+
if (treeNode) {
|
|
1275
|
+
// Chunked file - sum link sizes from decrypted tree node
|
|
1276
|
+
return treeNode.links.reduce((sum, l) => sum + l.size, 0);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// Single blob - fetch to check existence and get size
|
|
1280
|
+
const blob = await tree.getBlob(cid.hash);
|
|
1281
|
+
if (!blob) return null;
|
|
1282
|
+
|
|
1283
|
+
// For encrypted blobs, decrypted size = encrypted size - 16 (nonce overhead)
|
|
1284
|
+
return cid.key ? Math.max(0, blob.length - 16) : blob.length;
|
|
1285
|
+
},
|
|
1286
|
+
(value) => getLookupTtlMs(value),
|
|
1287
|
+
);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/**
|
|
1291
|
+
* Find actual thumbnail file in a directory
|
|
1292
|
+
*/
|
|
1293
|
+
async function findThumbnailLookupInDir(
|
|
1294
|
+
rootCid: CID,
|
|
1295
|
+
dirPath: string
|
|
1296
|
+
): Promise<ResolvedThumbnailLookup | null> {
|
|
1297
|
+
if (!tree) return null;
|
|
1298
|
+
|
|
1299
|
+
return loadCachedLookup(
|
|
1300
|
+
thumbnailPathLookupCache,
|
|
1301
|
+
`${cidCacheKey(rootCid)}|${dirPath}`,
|
|
1302
|
+
async () => {
|
|
1303
|
+
try {
|
|
1304
|
+
const dirEntry = dirPath
|
|
1305
|
+
? await resolvePathFromDirectoryListings(rootCid, dirPath)
|
|
1306
|
+
: { cid: rootCid };
|
|
1307
|
+
if (!dirEntry) return null;
|
|
1308
|
+
|
|
1309
|
+
const entries = await listDirectoryWithProbeTimeout(dirEntry.cid);
|
|
1310
|
+
if (!entries) return null;
|
|
1311
|
+
|
|
1312
|
+
const rootThumbnail = await findThumbnailLookupInEntries(entries, dirPath);
|
|
1313
|
+
if (rootThumbnail) {
|
|
1314
|
+
return rootThumbnail;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
const hasPlayableMediaFile = entries.some((entry) => isPlayableMediaFileName(entry.name));
|
|
1318
|
+
if (!hasPlayableMediaFile && entries.length > 0) {
|
|
1319
|
+
const sortedEntries = [...entries].sort((a, b) => a.name.localeCompare(b.name));
|
|
1320
|
+
for (const entry of sortedEntries.slice(0, 3)) {
|
|
1321
|
+
if (entry.name.endsWith('.json') || entry.name.endsWith('.txt')) {
|
|
1322
|
+
continue;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
try {
|
|
1326
|
+
const subEntries = await listDirectoryWithProbeTimeout(entry.cid);
|
|
1327
|
+
if (!subEntries) {
|
|
1328
|
+
continue;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
const prefix = dirPath ? `${dirPath}/${entry.name}` : entry.name;
|
|
1332
|
+
const nestedThumbnail = await findThumbnailLookupInEntries(subEntries, prefix);
|
|
1333
|
+
if (nestedThumbnail) {
|
|
1334
|
+
return nestedThumbnail;
|
|
1335
|
+
}
|
|
1336
|
+
} catch {
|
|
1337
|
+
continue;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
return null;
|
|
1343
|
+
} catch {
|
|
1344
|
+
return null;
|
|
1345
|
+
}
|
|
1346
|
+
},
|
|
1347
|
+
(value) => getLookupTtlMs(value),
|
|
1348
|
+
);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
async function findPlayableLookupInDir(
|
|
1352
|
+
rootCid: CID,
|
|
1353
|
+
dirPath: string
|
|
1354
|
+
): Promise<ResolvedRootEntry | null> {
|
|
1355
|
+
if (!tree) return null;
|
|
1356
|
+
|
|
1357
|
+
return loadCachedLookup(
|
|
1358
|
+
resolvedEntryLookupCache,
|
|
1359
|
+
`${cidCacheKey(rootCid)}|video|${dirPath}`,
|
|
1360
|
+
async () => {
|
|
1361
|
+
try {
|
|
1362
|
+
const dirEntry = dirPath
|
|
1363
|
+
? await resolvePathFromDirectoryListings(rootCid, dirPath)
|
|
1364
|
+
: { cid: rootCid, path: '' };
|
|
1365
|
+
if (!dirEntry) return null;
|
|
1366
|
+
|
|
1367
|
+
const entries = await listDirectoryWithProbeTimeout(dirEntry.cid);
|
|
1368
|
+
if (entries && entries.length > 0) {
|
|
1369
|
+
const directPlayable = findPlayableLookupInEntries(entries, dirPath);
|
|
1370
|
+
if (directPlayable) {
|
|
1371
|
+
return directPlayable;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
const sortedEntries = [...entries].sort((a, b) => a.name.localeCompare(b.name));
|
|
1375
|
+
for (const entry of sortedEntries.slice(0, 3)) {
|
|
1376
|
+
if (entry.name.endsWith('.json') || entry.name.endsWith('.txt') || !entry.cid) {
|
|
1377
|
+
continue;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
const prefix = dirPath ? `${dirPath}/${entry.name}` : entry.name;
|
|
1381
|
+
const subEntries = await listDirectoryWithProbeTimeout(entry.cid);
|
|
1382
|
+
if (!subEntries || subEntries.length === 0) {
|
|
1383
|
+
const directPlayableChild = await detectDirectPlayableLookup(entry.cid, prefix);
|
|
1384
|
+
if (directPlayableChild) {
|
|
1385
|
+
return directPlayableChild;
|
|
1386
|
+
}
|
|
1387
|
+
continue;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
const nestedPlayable = findPlayableLookupInEntries(subEntries, prefix);
|
|
1391
|
+
if (nestedPlayable) {
|
|
1392
|
+
return nestedPlayable;
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
return await detectDirectPlayableLookup(dirEntry.cid, dirPath);
|
|
1398
|
+
} catch {
|
|
1399
|
+
return null;
|
|
1400
|
+
}
|
|
1401
|
+
},
|
|
1402
|
+
(value) => getLookupTtlMs(value),
|
|
1403
|
+
);
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/**
|
|
1407
|
+
* Stream response to SW with proper HTTP headers
|
|
1408
|
+
*/
|
|
1409
|
+
async function streamSwResponse(
|
|
1410
|
+
requestId: string,
|
|
1411
|
+
cid: CID,
|
|
1412
|
+
totalSize: number,
|
|
1413
|
+
options: {
|
|
1414
|
+
npub?: string;
|
|
1415
|
+
path?: string;
|
|
1416
|
+
start?: number;
|
|
1417
|
+
end?: number;
|
|
1418
|
+
rangeHeader?: string | null;
|
|
1419
|
+
mimeType?: string;
|
|
1420
|
+
download?: boolean;
|
|
1421
|
+
}
|
|
1422
|
+
): Promise<void> {
|
|
1423
|
+
if (!tree || !mediaPort) return;
|
|
1424
|
+
|
|
1425
|
+
const { npub, path, start = 0, end, rangeHeader, mimeType = 'application/octet-stream', download } = options;
|
|
1426
|
+
|
|
1427
|
+
let rangeStart = start;
|
|
1428
|
+
let rangeEnd = end !== undefined ? Math.min(end, totalSize - 1) : totalSize - 1;
|
|
1429
|
+
if (rangeHeader) {
|
|
1430
|
+
const parsedRange = parseHttpByteRange(rangeHeader, totalSize);
|
|
1431
|
+
if (parsedRange.kind === 'range') {
|
|
1432
|
+
rangeStart = parsedRange.range.start;
|
|
1433
|
+
rangeEnd = parsedRange.range.endInclusive;
|
|
1434
|
+
} else if (parsedRange.kind === 'unsatisfiable') {
|
|
1435
|
+
sendSwError(requestId, 416, `Range not satisfiable for ${totalSize} byte file`);
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
const contentLength = rangeEnd - rangeStart + 1;
|
|
1440
|
+
|
|
1441
|
+
// Build cache control header
|
|
1442
|
+
const isNpubRequest = !!npub;
|
|
1443
|
+
const isImage = mimeType.startsWith('image/');
|
|
1444
|
+
let cacheControl: string;
|
|
1445
|
+
if (!isNpubRequest) {
|
|
1446
|
+
cacheControl = 'public, max-age=31536000, immutable'; // nhash: immutable
|
|
1447
|
+
} else if (isImage) {
|
|
1448
|
+
cacheControl = 'public, max-age=60, stale-while-revalidate=86400';
|
|
1449
|
+
} else {
|
|
1450
|
+
cacheControl = 'no-cache, no-store, must-revalidate';
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// Build headers
|
|
1454
|
+
const headers: Record<string, string> = {
|
|
1455
|
+
'Content-Type': mimeType,
|
|
1456
|
+
'Accept-Ranges': 'bytes',
|
|
1457
|
+
'Cache-Control': cacheControl,
|
|
1458
|
+
'Content-Length': String(contentLength),
|
|
1459
|
+
};
|
|
1460
|
+
|
|
1461
|
+
if (download) {
|
|
1462
|
+
const filename = path || 'file';
|
|
1463
|
+
headers['Content-Disposition'] = `attachment; filename="${filename}"`;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// Determine status (206 for range requests)
|
|
1467
|
+
const isRangeRequest = !!rangeHeader || end !== undefined || start > 0;
|
|
1468
|
+
const status = isRangeRequest ? 206 : 200;
|
|
1469
|
+
if (isRangeRequest) {
|
|
1470
|
+
headers['Content-Range'] = `bytes ${rangeStart}-${rangeEnd}/${totalSize}`;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
logMediaDebug('sw:response', {
|
|
1474
|
+
requestId,
|
|
1475
|
+
status,
|
|
1476
|
+
totalSize,
|
|
1477
|
+
rangeStart,
|
|
1478
|
+
rangeEnd,
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
// Send headers
|
|
1482
|
+
mediaPort.postMessage({
|
|
1483
|
+
type: 'headers',
|
|
1484
|
+
requestId,
|
|
1485
|
+
status,
|
|
1486
|
+
headers,
|
|
1487
|
+
totalSize,
|
|
1488
|
+
} as SwFileResponse);
|
|
1489
|
+
|
|
1490
|
+
// Stream chunks
|
|
1491
|
+
let offset = rangeStart;
|
|
1492
|
+
while (offset <= rangeEnd) {
|
|
1493
|
+
const chunkEnd = Math.min(offset + MEDIA_CHUNK_SIZE - 1, rangeEnd);
|
|
1494
|
+
const chunk = await tree.readFileRange(cid, offset, chunkEnd + 1);
|
|
1495
|
+
|
|
1496
|
+
if (!chunk) break;
|
|
1497
|
+
|
|
1498
|
+
mediaPort.postMessage(
|
|
1499
|
+
{ type: 'chunk', requestId, data: chunk } as SwFileResponse,
|
|
1500
|
+
[chunk.buffer]
|
|
1501
|
+
);
|
|
1502
|
+
|
|
1503
|
+
offset = chunkEnd + 1;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// Signal done
|
|
1507
|
+
mediaPort.postMessage({ type: 'done', requestId } as SwFileResponse);
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
function sendBufferedSwResponse(
|
|
1511
|
+
requestId: string,
|
|
1512
|
+
data: Uint8Array,
|
|
1513
|
+
options: {
|
|
1514
|
+
npub?: string;
|
|
1515
|
+
path?: string;
|
|
1516
|
+
mimeType?: string;
|
|
1517
|
+
download?: boolean;
|
|
1518
|
+
},
|
|
1519
|
+
): void {
|
|
1520
|
+
if (!mediaPort) return;
|
|
1521
|
+
|
|
1522
|
+
const { npub, path, mimeType = 'application/octet-stream', download } = options;
|
|
1523
|
+
const isNpubRequest = !!npub;
|
|
1524
|
+
const isImage = mimeType.startsWith('image/');
|
|
1525
|
+
let cacheControl: string;
|
|
1526
|
+
if (!isNpubRequest) {
|
|
1527
|
+
cacheControl = 'public, max-age=31536000, immutable';
|
|
1528
|
+
} else if (isImage) {
|
|
1529
|
+
cacheControl = 'public, max-age=60, stale-while-revalidate=86400';
|
|
1530
|
+
} else {
|
|
1531
|
+
cacheControl = 'no-cache, no-store, must-revalidate';
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
const headers: Record<string, string> = {
|
|
1535
|
+
'Content-Type': mimeType,
|
|
1536
|
+
'Content-Length': String(data.length),
|
|
1537
|
+
'Cache-Control': cacheControl,
|
|
1538
|
+
};
|
|
1539
|
+
|
|
1540
|
+
if (download) {
|
|
1541
|
+
headers['Content-Disposition'] = `attachment; filename="${path || 'file'}"`;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
mediaPort.postMessage({
|
|
1545
|
+
type: 'headers',
|
|
1546
|
+
requestId,
|
|
1547
|
+
status: 200,
|
|
1548
|
+
headers,
|
|
1549
|
+
totalSize: data.length,
|
|
1550
|
+
} as SwFileResponse);
|
|
1551
|
+
|
|
1552
|
+
mediaPort.postMessage(
|
|
1553
|
+
{ type: 'chunk', requestId, data } as SwFileResponse,
|
|
1554
|
+
[data.buffer]
|
|
1555
|
+
);
|
|
1556
|
+
mediaPort.postMessage({ type: 'done', requestId } as SwFileResponse);
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
interface ResolvedByteRange {
|
|
1560
|
+
start: number;
|
|
1561
|
+
endInclusive: number;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
type ParsedHttpRange =
|
|
1565
|
+
| { kind: 'range'; range: ResolvedByteRange }
|
|
1566
|
+
| { kind: 'unsatisfiable' }
|
|
1567
|
+
| { kind: 'unsupported' };
|
|
1568
|
+
|
|
1569
|
+
function parseHttpByteRange(
|
|
1570
|
+
rangeHeader: string | null | undefined,
|
|
1571
|
+
totalSize: number,
|
|
1572
|
+
): ParsedHttpRange {
|
|
1573
|
+
if (!rangeHeader) return { kind: 'unsupported' };
|
|
1574
|
+
const bytesRange = rangeHeader.startsWith('bytes=')
|
|
1575
|
+
? rangeHeader.slice('bytes='.length)
|
|
1576
|
+
: null;
|
|
1577
|
+
if (!bytesRange || bytesRange.includes(',')) return { kind: 'unsupported' };
|
|
1578
|
+
if (totalSize <= 0) return { kind: 'unsatisfiable' };
|
|
1579
|
+
|
|
1580
|
+
const parts = bytesRange.split('-', 2);
|
|
1581
|
+
if (parts.length !== 2) return { kind: 'unsupported' };
|
|
1582
|
+
const [startPart, endPart] = parts;
|
|
1583
|
+
|
|
1584
|
+
if (!startPart) {
|
|
1585
|
+
const suffixLength = Number.parseInt(endPart, 10);
|
|
1586
|
+
if (!Number.isFinite(suffixLength) || suffixLength <= 0) {
|
|
1587
|
+
return { kind: 'unsatisfiable' };
|
|
1588
|
+
}
|
|
1589
|
+
const clampedSuffix = Math.min(suffixLength, totalSize);
|
|
1590
|
+
return {
|
|
1591
|
+
kind: 'range',
|
|
1592
|
+
range: {
|
|
1593
|
+
start: totalSize - clampedSuffix,
|
|
1594
|
+
endInclusive: totalSize - 1,
|
|
1595
|
+
},
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
const start = Number.parseInt(startPart, 10);
|
|
1600
|
+
if (!Number.isFinite(start) || start < 0 || start >= totalSize) {
|
|
1601
|
+
return { kind: 'unsatisfiable' };
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
const endInclusive = endPart ? Number.parseInt(endPart, 10) : totalSize - 1;
|
|
1605
|
+
if (!Number.isFinite(endInclusive) || endInclusive < start) {
|
|
1606
|
+
return { kind: 'unsatisfiable' };
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
return {
|
|
1610
|
+
kind: 'range',
|
|
1611
|
+
range: {
|
|
1612
|
+
start,
|
|
1613
|
+
endInclusive: Math.min(endInclusive, totalSize - 1),
|
|
1614
|
+
},
|
|
1615
|
+
};
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
function isPlayableMediaFileName(name: string): boolean {
|
|
1619
|
+
const normalized = name.trim().toLowerCase();
|
|
1620
|
+
if (!normalized || normalized.endsWith('/')) {
|
|
1621
|
+
return false;
|
|
1622
|
+
}
|
|
1623
|
+
if (normalized.startsWith('video.')) {
|
|
1624
|
+
return true;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
const lastDot = normalized.lastIndexOf('.');
|
|
1628
|
+
if (lastDot === -1) {
|
|
1629
|
+
return false;
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
return PLAYABLE_MEDIA_EXTENSION_SET.has(normalized.slice(lastDot));
|
|
1633
|
+
}
|