Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

Fix directory index routing

+54 -21
+4 -2
apps/hosting-service/src/lib/file-serving.ts
··· 383 383 }; 384 384 385 385 const indexFiles = getIndexFiles(settings); 386 + const isDirectoryPathRequest = filePath.endsWith('/') && filePath.length > 0; 386 387 387 388 // Normalize the request path (keep empty for root, remove trailing slash for others) 388 389 let requestPath = filePath || ''; ··· 393 394 // For directory-like paths (empty or no file extension in basename), try index files 394 395 if (!requestPath || !hasFileExtension(requestPath)) { 395 396 // For non-empty extensionless paths, try as a direct file first (e.g. binary downloads) 396 - if (requestPath) { 397 + if (requestPath && !isDirectoryPathRequest) { 397 398 const directResult = await span(trace, `storage:${requestPath}`, () => getFileWithMetadata(did, rkey, requestPath)); 398 399 if (directResult) { 399 400 return buildResponseFromStorageResult(directResult, requestPath, settings, requestHeaders); ··· 694 695 }; 695 696 696 697 const indexFiles = getIndexFiles(settings); 698 + const isDirectoryPathRequest = filePath.endsWith('/') && filePath.length > 0; 697 699 const buildResponse = (fileResult: FileForRequestResult): Response => { 698 700 const meta = fileResult.result.metadata.customMetadata as { encoding?: string; mimeType?: string } | undefined; 699 701 const mimeType = meta?.mimeType || lookup(fileResult.filePath) || 'application/octet-stream'; ··· 716 718 // For directory-like paths (empty or no file extension in basename), try index files 717 719 if (!requestPath || !hasFileExtension(requestPath)) { 718 720 // For non-empty extensionless paths, try as a direct file first (e.g. binary downloads) 719 - if (requestPath) { 721 + if (requestPath && !isDirectoryPathRequest) { 720 722 const directResult = await span(trace, `storage:${requestPath}`, () => getFileForRequest(did, rkey, requestPath, true)); 721 723 if (directResult) { 722 724 return buildResponse(directResult);
+50 -19
packages/@wispplace/tiered-storage/src/tiers/DiskStorageTier.ts
··· 11 11 } from '../types/index.js'; 12 12 import { encodeKey, decodeKey } from '../utils/path-encoding.js'; 13 13 14 + function getErrnoCode(error: unknown): string | undefined { 15 + if (typeof error !== 'object' || error === null) return undefined; 16 + const maybeCode = (error as { code?: unknown }).code; 17 + return typeof maybeCode === 'string' ? maybeCode : undefined; 18 + } 19 + 14 20 /** 15 21 * Eviction policy for disk tier when size limit is reached. 16 22 */ ··· 180 186 const data = await readFile(filePath); 181 187 return new Uint8Array(data); 182 188 } catch (error) { 183 - const code = (error as NodeJS.ErrnoException).code; 184 - if (code === 'ENOENT' || code === 'ENOTDIR') { 189 + const code = getErrnoCode(error); 190 + if (code === 'ENOENT' || code === 'ENOTDIR' || code === 'EISDIR') { 185 191 return null; 186 192 } 187 193 throw error; ··· 222 228 223 229 return { data: new Uint8Array(dataBuffer), metadata }; 224 230 } catch (error) { 225 - const code = (error as NodeJS.ErrnoException).code; 226 - if (code === 'ENOENT' || code === 'ENOTDIR') { 231 + const code = getErrnoCode(error); 232 + if (code === 'ENOENT' || code === 'ENOTDIR' || code === 'EISDIR') { 227 233 return null; 228 234 } 229 235 if (error instanceof SyntaxError) { ··· 262 268 metadata.ttl = new Date(metadata.ttl); 263 269 } 264 270 271 + // Guard against directories being treated as files (causes EISDIR at read time). 272 + const dataStat = await stat(filePath); 273 + if (!dataStat.isFile()) { 274 + return null; 275 + } 276 + 265 277 // Create stream - will throw if file doesn't exist 266 278 const stream = createReadStream(filePath); 267 279 268 280 return { stream, metadata }; 269 281 } catch (error) { 270 - const code = (error as NodeJS.ErrnoException).code; 271 - if (code === 'ENOENT' || code === 'ENOTDIR') { 282 + const code = getErrnoCode(error); 283 + if (code === 'ENOENT' || code === 'ENOTDIR' || code === 'EISDIR') { 272 284 return null; 273 285 } 274 286 if (error instanceof SyntaxError) { ··· 311 323 await this.evictIfNeeded(metadata.size); 312 324 } 313 325 314 - // Write metadata first (atomic via temp file) 315 - const tempMetaPath = `${metaPath}.tmp`; 316 - await writeFile(tempMetaPath, JSON.stringify(metadata, null, 2)); 326 + // Write metadata first atomically so readers never observe partial JSON. 327 + await this.writeMetadataAtomically(metaPath, metadata); 317 328 318 329 // Stream data to file 319 330 const writeStream = createWriteStream(filePath); 320 331 await pipeline(stream, writeStream); 321 - 322 - // Commit metadata 323 - await rename(tempMetaPath, metaPath); 324 332 325 333 this.metadataIndex.set(key, { 326 334 size: metadata.size, ··· 348 356 await this.evictIfNeeded(data.byteLength); 349 357 } 350 358 351 - const tempMetaPath = `${metaPath}.tmp`; 352 - await writeFile(tempMetaPath, JSON.stringify(metadata, null, 2)); 359 + await this.writeMetadataAtomically(metaPath, metadata); 353 360 await writeFile(filePath, data); 354 - await rename(tempMetaPath, metaPath); 355 361 356 362 this.metadataIndex.set(key, { 357 363 size: data.byteLength, ··· 379 385 380 386 async exists(key: string): Promise<boolean> { 381 387 const filePath = this.getFilePath(key); 382 - return existsSync(filePath); 388 + try { 389 + const fileStat = await stat(filePath); 390 + return fileStat.isFile(); 391 + } catch (error) { 392 + const code = getErrnoCode(error); 393 + if (code === 'ENOENT' || code === 'ENOTDIR') { 394 + return false; 395 + } 396 + throw error; 397 + } 383 398 } 384 399 385 400 async *listKeys(prefix?: string): AsyncIterableIterator<string> { ··· 449 464 450 465 return metadata; 451 466 } catch (error) { 452 - const code = (error as NodeJS.ErrnoException).code; 453 - if (code === 'ENOENT' || code === 'ENOTDIR') { 467 + const code = getErrnoCode(error); 468 + if (code === 'ENOENT' || code === 'ENOTDIR' || code === 'EISDIR') { 454 469 return null; 455 470 } 456 471 if (error instanceof SyntaxError) { ··· 469 484 await mkdir(dir, { recursive: true }); 470 485 } 471 486 472 - await writeFile(metaPath, JSON.stringify(metadata, null, 2)); 487 + await this.writeMetadataAtomically(metaPath, metadata); 473 488 } 474 489 475 490 async getStats(): Promise<TierStats> { ··· 540 555 } catch { 541 556 // Directory doesn't exist or can't be read - that's fine 542 557 return; 558 + } 559 + } 560 + 561 + /** 562 + * Atomically write metadata to avoid readers seeing partial JSON. 563 + */ 564 + private async writeMetadataAtomically(metaPath: string, metadata: StorageMetadata): Promise<void> { 565 + const tempMetaPath = `${metaPath}.tmp-${process.pid}-${Date.now()}-${Math.random() 566 + .toString(16) 567 + .slice(2)}`; 568 + try { 569 + await writeFile(tempMetaPath, JSON.stringify(metadata, null, 2)); 570 + await rename(tempMetaPath, metaPath); 571 + } catch (error) { 572 + await unlink(tempMetaPath).catch(() => {}); 573 + throw error; 543 574 } 544 575 } 545 576