tangled
alpha
login
or
join now
nekomimi.pet
/
wisp.place-monorepo
88
fork
atom
Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
wisp.place
88
fork
atom
overview
issues
10
pulls
pipelines
Fix directory index routing
nekomimi.pet
2 weeks ago
3e297fcd
45e5d79f
1/2
deploy-wisp.yml
success
38s
test.yml
failed
30s
+54
-21
2 changed files
expand all
collapse all
unified
split
apps
hosting-service
src
lib
file-serving.ts
packages
@wispplace
tiered-storage
src
tiers
DiskStorageTier.ts
+4
-2
apps/hosting-service/src/lib/file-serving.ts
···
383
383
};
384
384
385
385
const indexFiles = getIndexFiles(settings);
386
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
396
-
if (requestPath) {
397
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
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
719
-
if (requestPath) {
721
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
14
+
function getErrnoCode(error: unknown): string | undefined {
15
15
+
if (typeof error !== 'object' || error === null) return undefined;
16
16
+
const maybeCode = (error as { code?: unknown }).code;
17
17
+
return typeof maybeCode === 'string' ? maybeCode : undefined;
18
18
+
}
19
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
183
-
const code = (error as NodeJS.ErrnoException).code;
184
184
-
if (code === 'ENOENT' || code === 'ENOTDIR') {
189
189
+
const code = getErrnoCode(error);
190
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
225
-
const code = (error as NodeJS.ErrnoException).code;
226
226
-
if (code === 'ENOENT' || code === 'ENOTDIR') {
231
231
+
const code = getErrnoCode(error);
232
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
271
+
// Guard against directories being treated as files (causes EISDIR at read time).
272
272
+
const dataStat = await stat(filePath);
273
273
+
if (!dataStat.isFile()) {
274
274
+
return null;
275
275
+
}
276
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
270
-
const code = (error as NodeJS.ErrnoException).code;
271
271
-
if (code === 'ENOENT' || code === 'ENOTDIR') {
282
282
+
const code = getErrnoCode(error);
283
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
314
-
// Write metadata first (atomic via temp file)
315
315
-
const tempMetaPath = `${metaPath}.tmp`;
316
316
-
await writeFile(tempMetaPath, JSON.stringify(metadata, null, 2));
326
326
+
// Write metadata first atomically so readers never observe partial JSON.
327
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
321
-
322
322
-
// Commit metadata
323
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
351
-
const tempMetaPath = `${metaPath}.tmp`;
352
352
-
await writeFile(tempMetaPath, JSON.stringify(metadata, null, 2));
359
359
+
await this.writeMetadataAtomically(metaPath, metadata);
353
360
await writeFile(filePath, data);
354
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
382
-
return existsSync(filePath);
388
388
+
try {
389
389
+
const fileStat = await stat(filePath);
390
390
+
return fileStat.isFile();
391
391
+
} catch (error) {
392
392
+
const code = getErrnoCode(error);
393
393
+
if (code === 'ENOENT' || code === 'ENOTDIR') {
394
394
+
return false;
395
395
+
}
396
396
+
throw error;
397
397
+
}
383
398
}
384
399
385
400
async *listKeys(prefix?: string): AsyncIterableIterator<string> {
···
449
464
450
465
return metadata;
451
466
} catch (error) {
452
452
-
const code = (error as NodeJS.ErrnoException).code;
453
453
-
if (code === 'ENOENT' || code === 'ENOTDIR') {
467
467
+
const code = getErrnoCode(error);
468
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
472
-
await writeFile(metaPath, JSON.stringify(metadata, null, 2));
487
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
558
+
}
559
559
+
}
560
560
+
561
561
+
/**
562
562
+
* Atomically write metadata to avoid readers seeing partial JSON.
563
563
+
*/
564
564
+
private async writeMetadataAtomically(metaPath: string, metadata: StorageMetadata): Promise<void> {
565
565
+
const tempMetaPath = `${metaPath}.tmp-${process.pid}-${Date.now()}-${Math.random()
566
566
+
.toString(16)
567
567
+
.slice(2)}`;
568
568
+
try {
569
569
+
await writeFile(tempMetaPath, JSON.stringify(metadata, null, 2));
570
570
+
await rename(tempMetaPath, metaPath);
571
571
+
} catch (error) {
572
572
+
await unlink(tempMetaPath).catch(() => {});
573
573
+
throw error;
543
574
}
544
575
}
545
576