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

refactor: migrate to maintainable monorepo structure

- Created 7 shared packages (@wisp/*)
- lexicons: 15k+ lines deduplicated
- observability: 670 lines deduplicated
- atproto-utils, fs-utils, database, constants, safe-fetch

Changed files
+3561 -4902
apps
hosting-service
main-app
public
scripts
src
hosting-service
packages
src
testDeploy
+5 -1
.gitignore
···
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
.env
# dependencies
-
/node_modules
/.pnp
.pnp.js
···
# production
/build
/result
# misc
.DS_Store
···
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
.env
# dependencies
+
node_modules
+
**/node_modules
/.pnp
.pnp.js
···
# production
/build
/result
+
dist
+
**/dist
+
*.tsbuildinfo
# misc
.DS_Store
+839
apps/hosting-service/src/lib/file-serving.ts
···
···
+
/**
+
* Core file serving logic for the hosting service
+
* Handles file retrieval, caching, redirects, and HTML rewriting
+
*/
+
+
import { readFile } from 'fs/promises';
+
import { lookup } from 'mime-types';
+
import type { Record as WispSettings } from '@wisp/lexicons/types/place/wisp/settings';
+
import { shouldCompressMimeType } from '@wisp/atproto-utils/compression';
+
import { fileCache, metadataCache, rewrittenHtmlCache, getCacheKey, isSiteBeingCached } from './cache';
+
import { getCachedFilePath, getCachedSettings } from './utils';
+
import { loadRedirectRules, matchRedirectRule, parseCookies, parseQueryString } from './redirects';
+
import { rewriteHtmlPaths, isHtmlContent } from './html-rewriter';
+
import { generate404Page, generateDirectoryListing, siteUpdatingResponse } from './page-generators';
+
import { getIndexFiles, applyCustomHeaders, fileExists } from './request-utils';
+
import { getRedirectRulesFromCache, setRedirectRulesInCache } from './site-cache';
+
+
/**
+
* Helper to serve files from cache (for custom domains and subdomains)
+
*/
+
export async function serveFromCache(
+
did: string,
+
rkey: string,
+
filePath: string,
+
fullUrl?: string,
+
headers?: Record<string, string>
+
): Promise<Response> {
+
// Load settings for this site
+
const settings = await getCachedSettings(did, rkey);
+
const indexFiles = getIndexFiles(settings);
+
+
// Check for redirect rules first (_redirects wins over settings)
+
let redirectRules = getRedirectRulesFromCache(did, rkey);
+
+
if (redirectRules === undefined) {
+
// Load rules for the first time
+
redirectRules = await loadRedirectRules(did, rkey);
+
setRedirectRulesInCache(did, rkey, redirectRules);
+
}
+
+
// Apply redirect rules if any exist
+
if (redirectRules.length > 0) {
+
const requestPath = '/' + (filePath || '');
+
const queryParams = fullUrl ? parseQueryString(fullUrl) : {};
+
const cookies = parseCookies(headers?.['cookie']);
+
+
const redirectMatch = matchRedirectRule(requestPath, redirectRules, {
+
queryParams,
+
headers,
+
cookies,
+
});
+
+
if (redirectMatch) {
+
const { rule, targetPath, status } = redirectMatch;
+
+
// If not forced, check if the requested file exists before redirecting
+
if (!rule.force) {
+
// Build the expected file path
+
let checkPath: string = filePath || indexFiles[0] || 'index.html';
+
if (checkPath.endsWith('/')) {
+
checkPath += indexFiles[0] || 'index.html';
+
}
+
+
const cachedFile = getCachedFilePath(did, rkey, checkPath);
+
const fileExistsOnDisk = await fileExists(cachedFile);
+
+
// If file exists and redirect is not forced, serve the file normally
+
if (fileExistsOnDisk) {
+
return serveFileInternal(did, rkey, filePath, settings);
+
}
+
}
+
+
// Handle different status codes
+
if (status === 200) {
+
// Rewrite: serve different content but keep URL the same
+
// Remove leading slash for internal path resolution
+
const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
+
return serveFileInternal(did, rkey, rewritePath, settings);
+
} else if (status === 301 || status === 302) {
+
// External redirect: change the URL
+
return new Response(null, {
+
status,
+
headers: {
+
'Location': targetPath,
+
'Cache-Control': status === 301 ? 'public, max-age=31536000' : 'public, max-age=0',
+
},
+
});
+
} else if (status === 404) {
+
// Custom 404 page from _redirects (wins over settings.custom404)
+
const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
+
const response = await serveFileInternal(did, rkey, custom404Path, settings);
+
// Override status to 404
+
return new Response(response.body, {
+
status: 404,
+
headers: response.headers,
+
});
+
}
+
}
+
}
+
+
// No redirect matched, serve normally with settings
+
return serveFileInternal(did, rkey, filePath, settings);
+
}
+
+
/**
+
* Internal function to serve a file (used by both normal serving and rewrites)
+
*/
+
export async function serveFileInternal(
+
did: string,
+
rkey: string,
+
filePath: string,
+
settings: WispSettings | null = null
+
): Promise<Response> {
+
// Check if site is currently being cached - if so, return updating response
+
if (isSiteBeingCached(did, rkey)) {
+
return siteUpdatingResponse();
+
}
+
+
const indexFiles = getIndexFiles(settings);
+
+
// Normalize the request path (keep empty for root, remove trailing slash for others)
+
let requestPath = filePath || '';
+
if (requestPath.endsWith('/') && requestPath.length > 1) {
+
requestPath = requestPath.slice(0, -1);
+
}
+
+
// Check if this path is a directory first
+
const directoryPath = getCachedFilePath(did, rkey, requestPath);
+
if (await fileExists(directoryPath)) {
+
const { stat, readdir } = await import('fs/promises');
+
try {
+
const stats = await stat(directoryPath);
+
if (stats.isDirectory()) {
+
// It's a directory, try each index file in order
+
for (const indexFile of indexFiles) {
+
const indexPath = requestPath ? `${requestPath}/${indexFile}` : indexFile;
+
const indexFilePath = getCachedFilePath(did, rkey, indexPath);
+
if (await fileExists(indexFilePath)) {
+
return serveFileInternal(did, rkey, indexPath, settings);
+
}
+
}
+
// No index file found - check if directory listing is enabled
+
if (settings?.directoryListing) {
+
const { stat } = await import('fs/promises');
+
const entries = await readdir(directoryPath);
+
// Filter out .meta files and other hidden files
+
const visibleEntries = entries.filter(entry => !entry.endsWith('.meta') && entry !== '.metadata.json');
+
+
// Check which entries are directories
+
const entriesWithType = await Promise.all(
+
visibleEntries.map(async (name) => {
+
try {
+
const entryPath = `${directoryPath}/${name}`;
+
const stats = await stat(entryPath);
+
return { name, isDirectory: stats.isDirectory() };
+
} catch {
+
return { name, isDirectory: false };
+
}
+
})
+
);
+
+
const html = generateDirectoryListing(requestPath, entriesWithType);
+
return new Response(html, {
+
headers: {
+
'Content-Type': 'text/html; charset=utf-8',
+
'Cache-Control': 'public, max-age=300',
+
},
+
});
+
}
+
// Fall through to 404/SPA handling
+
}
+
} catch (err) {
+
// If stat fails, continue with normal flow
+
}
+
}
+
+
// Not a directory, try to serve as a file
+
const fileRequestPath: string = requestPath || indexFiles[0] || 'index.html';
+
const cacheKey = getCacheKey(did, rkey, fileRequestPath);
+
const cachedFile = getCachedFilePath(did, rkey, fileRequestPath);
+
+
// Check in-memory cache first
+
let content = fileCache.get(cacheKey);
+
let meta = metadataCache.get(cacheKey);
+
+
if (!content && await fileExists(cachedFile)) {
+
// Read from disk and cache
+
content = await readFile(cachedFile);
+
fileCache.set(cacheKey, content, content.length);
+
+
const metaFile = `${cachedFile}.meta`;
+
if (await fileExists(metaFile)) {
+
const metaJson = await readFile(metaFile, 'utf-8');
+
meta = JSON.parse(metaJson);
+
metadataCache.set(cacheKey, meta!, JSON.stringify(meta).length);
+
}
+
}
+
+
if (content) {
+
// Build headers with caching
+
const headers: Record<string, string> = {};
+
+
if (meta && meta.encoding === 'gzip' && meta.mimeType) {
+
const shouldServeCompressed = shouldCompressMimeType(meta.mimeType);
+
+
if (!shouldServeCompressed) {
+
// Verify content is actually gzipped before attempting decompression
+
const isGzipped = content.length >= 2 && content[0] === 0x1f && content[1] === 0x8b;
+
if (isGzipped) {
+
const { gunzipSync } = await import('zlib');
+
const decompressed = gunzipSync(content);
+
headers['Content-Type'] = meta.mimeType;
+
headers['Cache-Control'] = 'public, max-age=31536000, immutable';
+
applyCustomHeaders(headers, fileRequestPath, settings);
+
return new Response(decompressed, { headers });
+
} else {
+
// Meta says gzipped but content isn't - serve as-is
+
console.warn(`File ${filePath} has gzip encoding in meta but content lacks gzip magic bytes`);
+
headers['Content-Type'] = meta.mimeType;
+
headers['Cache-Control'] = 'public, max-age=31536000, immutable';
+
applyCustomHeaders(headers, fileRequestPath, settings);
+
return new Response(content, { headers });
+
}
+
}
+
+
headers['Content-Type'] = meta.mimeType;
+
headers['Content-Encoding'] = 'gzip';
+
headers['Cache-Control'] = meta.mimeType.startsWith('text/html')
+
? 'public, max-age=300'
+
: 'public, max-age=31536000, immutable';
+
applyCustomHeaders(headers, fileRequestPath, settings);
+
return new Response(content, { headers });
+
}
+
+
// Non-compressed files
+
const mimeType = lookup(cachedFile) || 'application/octet-stream';
+
headers['Content-Type'] = mimeType;
+
headers['Cache-Control'] = mimeType.startsWith('text/html')
+
? 'public, max-age=300'
+
: 'public, max-age=31536000, immutable';
+
applyCustomHeaders(headers, fileRequestPath, settings);
+
return new Response(content, { headers });
+
}
+
+
// Try index files for directory-like paths
+
if (!fileRequestPath.includes('.')) {
+
for (const indexFileName of indexFiles) {
+
const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName;
+
const indexCacheKey = getCacheKey(did, rkey, indexPath);
+
const indexFile = getCachedFilePath(did, rkey, indexPath);
+
+
let indexContent = fileCache.get(indexCacheKey);
+
let indexMeta = metadataCache.get(indexCacheKey);
+
+
if (!indexContent && await fileExists(indexFile)) {
+
indexContent = await readFile(indexFile);
+
fileCache.set(indexCacheKey, indexContent, indexContent.length);
+
+
const indexMetaFile = `${indexFile}.meta`;
+
if (await fileExists(indexMetaFile)) {
+
const metaJson = await readFile(indexMetaFile, 'utf-8');
+
indexMeta = JSON.parse(metaJson);
+
metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length);
+
}
+
}
+
+
if (indexContent) {
+
const headers: Record<string, string> = {
+
'Content-Type': 'text/html; charset=utf-8',
+
'Cache-Control': 'public, max-age=300',
+
};
+
+
if (indexMeta && indexMeta.encoding === 'gzip') {
+
headers['Content-Encoding'] = 'gzip';
+
}
+
+
applyCustomHeaders(headers, indexPath, settings);
+
return new Response(indexContent, { headers });
+
}
+
}
+
}
+
+
// Try clean URLs: /about -> /about.html
+
if (settings?.cleanUrls && !fileRequestPath.includes('.')) {
+
const htmlPath = `${fileRequestPath}.html`;
+
const htmlFile = getCachedFilePath(did, rkey, htmlPath);
+
if (await fileExists(htmlFile)) {
+
return serveFileInternal(did, rkey, htmlPath, settings);
+
}
+
+
// Also try /about/index.html
+
for (const indexFileName of indexFiles) {
+
const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName;
+
const indexFile = getCachedFilePath(did, rkey, indexPath);
+
if (await fileExists(indexFile)) {
+
return serveFileInternal(did, rkey, indexPath, settings);
+
}
+
}
+
}
+
+
// SPA mode: serve SPA file for all non-existing routes (wins over custom404 but loses to _redirects)
+
if (settings?.spaMode) {
+
const spaFile = settings.spaMode;
+
const spaFilePath = getCachedFilePath(did, rkey, spaFile);
+
if (await fileExists(spaFilePath)) {
+
return serveFileInternal(did, rkey, spaFile, settings);
+
}
+
}
+
+
// Custom 404: serve custom 404 file if configured (wins conflict battle)
+
if (settings?.custom404) {
+
const custom404File = settings.custom404;
+
const custom404Path = getCachedFilePath(did, rkey, custom404File);
+
if (await fileExists(custom404Path)) {
+
const response: Response = await serveFileInternal(did, rkey, custom404File, settings);
+
// Override status to 404
+
return new Response(response.body, {
+
status: 404,
+
headers: response.headers,
+
});
+
}
+
}
+
+
// Autodetect 404 pages (GitHub Pages: 404.html, Neocities/Nekoweb: not_found.html)
+
const auto404Pages = ['404.html', 'not_found.html'];
+
for (const auto404Page of auto404Pages) {
+
const auto404Path = getCachedFilePath(did, rkey, auto404Page);
+
if (await fileExists(auto404Path)) {
+
const response: Response = await serveFileInternal(did, rkey, auto404Page, settings);
+
// Override status to 404
+
return new Response(response.body, {
+
status: 404,
+
headers: response.headers,
+
});
+
}
+
}
+
+
// Directory listing fallback: if enabled, show root directory listing on 404
+
if (settings?.directoryListing) {
+
const rootPath = getCachedFilePath(did, rkey, '');
+
if (await fileExists(rootPath)) {
+
const { stat, readdir } = await import('fs/promises');
+
try {
+
const stats = await stat(rootPath);
+
if (stats.isDirectory()) {
+
const entries = await readdir(rootPath);
+
// Filter out .meta files and metadata
+
const visibleEntries = entries.filter(entry =>
+
!entry.endsWith('.meta') && entry !== '.metadata.json'
+
);
+
+
// Check which entries are directories
+
const entriesWithType = await Promise.all(
+
visibleEntries.map(async (name) => {
+
try {
+
const entryPath = `${rootPath}/${name}`;
+
const entryStats = await stat(entryPath);
+
return { name, isDirectory: entryStats.isDirectory() };
+
} catch {
+
return { name, isDirectory: false };
+
}
+
})
+
);
+
+
const html = generateDirectoryListing('', entriesWithType);
+
return new Response(html, {
+
status: 404,
+
headers: {
+
'Content-Type': 'text/html; charset=utf-8',
+
'Cache-Control': 'public, max-age=300',
+
},
+
});
+
}
+
} catch (err) {
+
// If directory listing fails, fall through to 404
+
}
+
}
+
}
+
+
// Default styled 404 page
+
const html = generate404Page();
+
return new Response(html, {
+
status: 404,
+
headers: {
+
'Content-Type': 'text/html; charset=utf-8',
+
'Cache-Control': 'public, max-age=300',
+
},
+
});
+
}
+
+
/**
+
* Helper to serve files from cache with HTML path rewriting for sites.wisp.place routes
+
*/
+
export async function serveFromCacheWithRewrite(
+
did: string,
+
rkey: string,
+
filePath: string,
+
basePath: string,
+
fullUrl?: string,
+
headers?: Record<string, string>
+
): Promise<Response> {
+
// Load settings for this site
+
const settings = await getCachedSettings(did, rkey);
+
const indexFiles = getIndexFiles(settings);
+
+
// Check for redirect rules first (_redirects wins over settings)
+
let redirectRules = getRedirectRulesFromCache(did, rkey);
+
+
if (redirectRules === undefined) {
+
// Load rules for the first time
+
redirectRules = await loadRedirectRules(did, rkey);
+
setRedirectRulesInCache(did, rkey, redirectRules);
+
}
+
+
// Apply redirect rules if any exist
+
if (redirectRules.length > 0) {
+
const requestPath = '/' + (filePath || '');
+
const queryParams = fullUrl ? parseQueryString(fullUrl) : {};
+
const cookies = parseCookies(headers?.['cookie']);
+
+
const redirectMatch = matchRedirectRule(requestPath, redirectRules, {
+
queryParams,
+
headers,
+
cookies,
+
});
+
+
if (redirectMatch) {
+
const { rule, targetPath, status } = redirectMatch;
+
+
// If not forced, check if the requested file exists before redirecting
+
if (!rule.force) {
+
// Build the expected file path
+
let checkPath: string = filePath || indexFiles[0] || 'index.html';
+
if (checkPath.endsWith('/')) {
+
checkPath += indexFiles[0] || 'index.html';
+
}
+
+
const cachedFile = getCachedFilePath(did, rkey, checkPath);
+
const fileExistsOnDisk = await fileExists(cachedFile);
+
+
// If file exists and redirect is not forced, serve the file normally
+
if (fileExistsOnDisk) {
+
return serveFileInternalWithRewrite(did, rkey, filePath, basePath, settings);
+
}
+
}
+
+
// Handle different status codes
+
if (status === 200) {
+
// Rewrite: serve different content but keep URL the same
+
const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
+
return serveFileInternalWithRewrite(did, rkey, rewritePath, basePath, settings);
+
} else if (status === 301 || status === 302) {
+
// External redirect: change the URL
+
// For sites.wisp.place, we need to adjust the target path to include the base path
+
// unless it's an absolute URL
+
let redirectTarget = targetPath;
+
if (!targetPath.startsWith('http://') && !targetPath.startsWith('https://')) {
+
redirectTarget = basePath + (targetPath.startsWith('/') ? targetPath.slice(1) : targetPath);
+
}
+
return new Response(null, {
+
status,
+
headers: {
+
'Location': redirectTarget,
+
'Cache-Control': status === 301 ? 'public, max-age=31536000' : 'public, max-age=0',
+
},
+
});
+
} else if (status === 404) {
+
// Custom 404 page from _redirects (wins over settings.custom404)
+
const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
+
const response = await serveFileInternalWithRewrite(did, rkey, custom404Path, basePath, settings);
+
// Override status to 404
+
return new Response(response.body, {
+
status: 404,
+
headers: response.headers,
+
});
+
}
+
}
+
}
+
+
// No redirect matched, serve normally with settings
+
return serveFileInternalWithRewrite(did, rkey, filePath, basePath, settings);
+
}
+
+
/**
+
* Internal function to serve a file with rewriting
+
*/
+
export async function serveFileInternalWithRewrite(
+
did: string,
+
rkey: string,
+
filePath: string,
+
basePath: string,
+
settings: WispSettings | null = null
+
): Promise<Response> {
+
// Check if site is currently being cached - if so, return updating response
+
if (isSiteBeingCached(did, rkey)) {
+
return siteUpdatingResponse();
+
}
+
+
const indexFiles = getIndexFiles(settings);
+
+
// Normalize the request path (keep empty for root, remove trailing slash for others)
+
let requestPath = filePath || '';
+
if (requestPath.endsWith('/') && requestPath.length > 1) {
+
requestPath = requestPath.slice(0, -1);
+
}
+
+
// Check if this path is a directory first
+
const directoryPath = getCachedFilePath(did, rkey, requestPath);
+
if (await fileExists(directoryPath)) {
+
const { stat, readdir } = await import('fs/promises');
+
try {
+
const stats = await stat(directoryPath);
+
if (stats.isDirectory()) {
+
// It's a directory, try each index file in order
+
for (const indexFile of indexFiles) {
+
const indexPath = requestPath ? `${requestPath}/${indexFile}` : indexFile;
+
const indexFilePath = getCachedFilePath(did, rkey, indexPath);
+
if (await fileExists(indexFilePath)) {
+
return serveFileInternalWithRewrite(did, rkey, indexPath, basePath, settings);
+
}
+
}
+
// No index file found - check if directory listing is enabled
+
if (settings?.directoryListing) {
+
const { stat } = await import('fs/promises');
+
const entries = await readdir(directoryPath);
+
// Filter out .meta files and other hidden files
+
const visibleEntries = entries.filter(entry => !entry.endsWith('.meta') && entry !== '.metadata.json');
+
+
// Check which entries are directories
+
const entriesWithType = await Promise.all(
+
visibleEntries.map(async (name) => {
+
try {
+
const entryPath = `${directoryPath}/${name}`;
+
const stats = await stat(entryPath);
+
return { name, isDirectory: stats.isDirectory() };
+
} catch {
+
return { name, isDirectory: false };
+
}
+
})
+
);
+
+
const html = generateDirectoryListing(requestPath, entriesWithType);
+
return new Response(html, {
+
headers: {
+
'Content-Type': 'text/html; charset=utf-8',
+
'Cache-Control': 'public, max-age=300',
+
},
+
});
+
}
+
// Fall through to 404/SPA handling
+
}
+
} catch (err) {
+
// If stat fails, continue with normal flow
+
}
+
}
+
+
// Not a directory, try to serve as a file
+
const fileRequestPath: string = requestPath || indexFiles[0] || 'index.html';
+
const cacheKey = getCacheKey(did, rkey, fileRequestPath);
+
const cachedFile = getCachedFilePath(did, rkey, fileRequestPath);
+
+
// Check for rewritten HTML in cache first (if it's HTML)
+
const mimeTypeGuess = lookup(fileRequestPath) || 'application/octet-stream';
+
if (isHtmlContent(fileRequestPath, mimeTypeGuess)) {
+
const rewrittenKey = getCacheKey(did, rkey, fileRequestPath, `rewritten:${basePath}`);
+
const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey);
+
if (rewrittenContent) {
+
const headers: Record<string, string> = {
+
'Content-Type': 'text/html; charset=utf-8',
+
'Content-Encoding': 'gzip',
+
'Cache-Control': 'public, max-age=300',
+
};
+
applyCustomHeaders(headers, fileRequestPath, settings);
+
return new Response(rewrittenContent, { headers });
+
}
+
}
+
+
// Check in-memory file cache
+
let content = fileCache.get(cacheKey);
+
let meta = metadataCache.get(cacheKey);
+
+
if (!content && await fileExists(cachedFile)) {
+
// Read from disk and cache
+
content = await readFile(cachedFile);
+
fileCache.set(cacheKey, content, content.length);
+
+
const metaFile = `${cachedFile}.meta`;
+
if (await fileExists(metaFile)) {
+
const metaJson = await readFile(metaFile, 'utf-8');
+
meta = JSON.parse(metaJson);
+
metadataCache.set(cacheKey, meta!, JSON.stringify(meta).length);
+
}
+
}
+
+
if (content) {
+
const mimeType = meta?.mimeType || lookup(cachedFile) || 'application/octet-stream';
+
const isGzipped = meta?.encoding === 'gzip';
+
+
// Check if this is HTML content that needs rewriting
+
if (isHtmlContent(fileRequestPath, mimeType)) {
+
let htmlContent: string;
+
if (isGzipped) {
+
// Verify content is actually gzipped
+
const hasGzipMagic = content.length >= 2 && content[0] === 0x1f && content[1] === 0x8b;
+
if (hasGzipMagic) {
+
const { gunzipSync } = await import('zlib');
+
htmlContent = gunzipSync(content).toString('utf-8');
+
} else {
+
console.warn(`File ${fileRequestPath} marked as gzipped but lacks magic bytes, serving as-is`);
+
htmlContent = content.toString('utf-8');
+
}
+
} else {
+
htmlContent = content.toString('utf-8');
+
}
+
const rewritten = rewriteHtmlPaths(htmlContent, basePath, fileRequestPath);
+
+
// Recompress and cache the rewritten HTML
+
const { gzipSync } = await import('zlib');
+
const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8'));
+
+
const rewrittenKey = getCacheKey(did, rkey, fileRequestPath, `rewritten:${basePath}`);
+
rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length);
+
+
const htmlHeaders: Record<string, string> = {
+
'Content-Type': 'text/html; charset=utf-8',
+
'Content-Encoding': 'gzip',
+
'Cache-Control': 'public, max-age=300',
+
};
+
applyCustomHeaders(htmlHeaders, fileRequestPath, settings);
+
return new Response(recompressed, { headers: htmlHeaders });
+
}
+
+
// Non-HTML files: serve as-is
+
const headers: Record<string, string> = {
+
'Content-Type': mimeType,
+
'Cache-Control': 'public, max-age=31536000, immutable',
+
};
+
+
if (isGzipped) {
+
const shouldServeCompressed = shouldCompressMimeType(mimeType);
+
if (!shouldServeCompressed) {
+
// Verify content is actually gzipped
+
const hasGzipMagic = content.length >= 2 && content[0] === 0x1f && content[1] === 0x8b;
+
if (hasGzipMagic) {
+
const { gunzipSync } = await import('zlib');
+
const decompressed = gunzipSync(content);
+
applyCustomHeaders(headers, fileRequestPath, settings);
+
return new Response(decompressed, { headers });
+
} else {
+
console.warn(`File ${fileRequestPath} marked as gzipped but lacks magic bytes, serving as-is`);
+
applyCustomHeaders(headers, fileRequestPath, settings);
+
return new Response(content, { headers });
+
}
+
}
+
headers['Content-Encoding'] = 'gzip';
+
}
+
+
applyCustomHeaders(headers, fileRequestPath, settings);
+
return new Response(content, { headers });
+
}
+
+
// Try index files for directory-like paths
+
if (!fileRequestPath.includes('.')) {
+
for (const indexFileName of indexFiles) {
+
const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName;
+
const indexCacheKey = getCacheKey(did, rkey, indexPath);
+
const indexFile = getCachedFilePath(did, rkey, indexPath);
+
+
// Check for rewritten index file in cache
+
const rewrittenKey = getCacheKey(did, rkey, indexPath, `rewritten:${basePath}`);
+
const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey);
+
if (rewrittenContent) {
+
const headers: Record<string, string> = {
+
'Content-Type': 'text/html; charset=utf-8',
+
'Content-Encoding': 'gzip',
+
'Cache-Control': 'public, max-age=300',
+
};
+
applyCustomHeaders(headers, indexPath, settings);
+
return new Response(rewrittenContent, { headers });
+
}
+
+
let indexContent = fileCache.get(indexCacheKey);
+
let indexMeta = metadataCache.get(indexCacheKey);
+
+
if (!indexContent && await fileExists(indexFile)) {
+
indexContent = await readFile(indexFile);
+
fileCache.set(indexCacheKey, indexContent, indexContent.length);
+
+
const indexMetaFile = `${indexFile}.meta`;
+
if (await fileExists(indexMetaFile)) {
+
const metaJson = await readFile(indexMetaFile, 'utf-8');
+
indexMeta = JSON.parse(metaJson);
+
metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length);
+
}
+
}
+
+
if (indexContent) {
+
const isGzipped = indexMeta?.encoding === 'gzip';
+
+
let htmlContent: string;
+
if (isGzipped) {
+
// Verify content is actually gzipped
+
const hasGzipMagic = indexContent.length >= 2 && indexContent[0] === 0x1f && indexContent[1] === 0x8b;
+
if (hasGzipMagic) {
+
const { gunzipSync } = await import('zlib');
+
htmlContent = gunzipSync(indexContent).toString('utf-8');
+
} else {
+
console.warn(`Index file marked as gzipped but lacks magic bytes, serving as-is`);
+
htmlContent = indexContent.toString('utf-8');
+
}
+
} else {
+
htmlContent = indexContent.toString('utf-8');
+
}
+
const rewritten = rewriteHtmlPaths(htmlContent, basePath, indexPath);
+
+
const { gzipSync } = await import('zlib');
+
const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8'));
+
+
rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length);
+
+
const headers: Record<string, string> = {
+
'Content-Type': 'text/html; charset=utf-8',
+
'Content-Encoding': 'gzip',
+
'Cache-Control': 'public, max-age=300',
+
};
+
applyCustomHeaders(headers, indexPath, settings);
+
return new Response(recompressed, { headers });
+
}
+
}
+
}
+
+
// Try clean URLs: /about -> /about.html
+
if (settings?.cleanUrls && !fileRequestPath.includes('.')) {
+
const htmlPath = `${fileRequestPath}.html`;
+
const htmlFile = getCachedFilePath(did, rkey, htmlPath);
+
if (await fileExists(htmlFile)) {
+
return serveFileInternalWithRewrite(did, rkey, htmlPath, basePath, settings);
+
}
+
+
// Also try /about/index.html
+
for (const indexFileName of indexFiles) {
+
const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName;
+
const indexFile = getCachedFilePath(did, rkey, indexPath);
+
if (await fileExists(indexFile)) {
+
return serveFileInternalWithRewrite(did, rkey, indexPath, basePath, settings);
+
}
+
}
+
}
+
+
// SPA mode: serve SPA file for all non-existing routes
+
if (settings?.spaMode) {
+
const spaFile = settings.spaMode;
+
const spaFilePath = getCachedFilePath(did, rkey, spaFile);
+
if (await fileExists(spaFilePath)) {
+
return serveFileInternalWithRewrite(did, rkey, spaFile, basePath, settings);
+
}
+
}
+
+
// Custom 404: serve custom 404 file if configured (wins conflict battle)
+
if (settings?.custom404) {
+
const custom404File = settings.custom404;
+
const custom404Path = getCachedFilePath(did, rkey, custom404File);
+
if (await fileExists(custom404Path)) {
+
const response: Response = await serveFileInternalWithRewrite(did, rkey, custom404File, basePath, settings);
+
// Override status to 404
+
return new Response(response.body, {
+
status: 404,
+
headers: response.headers,
+
});
+
}
+
}
+
+
// Autodetect 404 pages (GitHub Pages: 404.html, Neocities/Nekoweb: not_found.html)
+
const auto404Pages = ['404.html', 'not_found.html'];
+
for (const auto404Page of auto404Pages) {
+
const auto404Path = getCachedFilePath(did, rkey, auto404Page);
+
if (await fileExists(auto404Path)) {
+
const response: Response = await serveFileInternalWithRewrite(did, rkey, auto404Page, basePath, settings);
+
// Override status to 404
+
return new Response(response.body, {
+
status: 404,
+
headers: response.headers,
+
});
+
}
+
}
+
+
// Directory listing fallback: if enabled, show root directory listing on 404
+
if (settings?.directoryListing) {
+
const rootPath = getCachedFilePath(did, rkey, '');
+
if (await fileExists(rootPath)) {
+
const { stat, readdir } = await import('fs/promises');
+
try {
+
const stats = await stat(rootPath);
+
if (stats.isDirectory()) {
+
const entries = await readdir(rootPath);
+
// Filter out .meta files and metadata
+
const visibleEntries = entries.filter(entry =>
+
!entry.endsWith('.meta') && entry !== '.metadata.json'
+
);
+
+
// Check which entries are directories
+
const entriesWithType = await Promise.all(
+
visibleEntries.map(async (name) => {
+
try {
+
const entryPath = `${rootPath}/${name}`;
+
const entryStats = await stat(entryPath);
+
return { name, isDirectory: entryStats.isDirectory() };
+
} catch {
+
return { name, isDirectory: false };
+
}
+
})
+
);
+
+
const html = generateDirectoryListing('', entriesWithType);
+
return new Response(html, {
+
status: 404,
+
headers: {
+
'Content-Type': 'text/html; charset=utf-8',
+
'Cache-Control': 'public, max-age=300',
+
},
+
});
+
}
+
} catch (err) {
+
// If directory listing fails, fall through to 404
+
}
+
}
+
}
+
+
// Default styled 404 page
+
const html = generate404Page();
+
return new Response(html, {
+
status: 404,
+
headers: {
+
'Content-Type': 'text/html; charset=utf-8',
+
'Cache-Control': 'public, max-age=300',
+
},
+
});
+
}
+
+362
apps/hosting-service/src/lib/page-generators.ts
···
···
+
/**
+
* HTML page generation utilities for hosting service
+
* Generates 404 pages, directory listings, and updating pages
+
*/
+
+
/**
+
* Generate 404 page HTML
+
*/
+
export function generate404Page(): string {
+
const html = `<!DOCTYPE html>
+
<html>
+
<head>
+
<meta charset="utf-8">
+
<meta name="viewport" content="width=device-width, initial-scale=1">
+
<title>404 - Not Found</title>
+
<style>
+
@media (prefers-color-scheme: light) {
+
:root {
+
/* Warm beige background */
+
--background: oklch(0.90 0.012 35);
+
/* Very dark brown text */
+
--foreground: oklch(0.18 0.01 30);
+
--border: oklch(0.75 0.015 30);
+
/* Bright pink accent for links */
+
--accent: oklch(0.78 0.15 345);
+
}
+
}
+
@media (prefers-color-scheme: dark) {
+
:root {
+
/* Slate violet background */
+
--background: oklch(0.23 0.015 285);
+
/* Light gray text */
+
--foreground: oklch(0.90 0.005 285);
+
/* Subtle borders */
+
--border: oklch(0.38 0.02 285);
+
/* Soft pink accent */
+
--accent: oklch(0.85 0.08 5);
+
}
+
}
+
body {
+
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
+
background: var(--background);
+
color: var(--foreground);
+
padding: 2rem;
+
max-width: 800px;
+
margin: 0 auto;
+
display: flex;
+
flex-direction: column;
+
min-height: 100vh;
+
justify-content: center;
+
align-items: center;
+
text-align: center;
+
}
+
h1 {
+
font-size: 6rem;
+
margin: 0;
+
font-weight: 700;
+
line-height: 1;
+
}
+
h2 {
+
font-size: 1.5rem;
+
margin: 1rem 0 2rem;
+
font-weight: 400;
+
opacity: 0.8;
+
}
+
p {
+
font-size: 1rem;
+
opacity: 0.7;
+
margin-bottom: 2rem;
+
}
+
a {
+
color: var(--accent);
+
text-decoration: none;
+
font-size: 1rem;
+
}
+
a:hover {
+
text-decoration: underline;
+
}
+
footer {
+
margin-top: 2rem;
+
padding-top: 1.5rem;
+
border-top: 1px solid var(--border);
+
text-align: center;
+
font-size: 0.875rem;
+
opacity: 0.7;
+
color: var(--foreground);
+
}
+
footer a {
+
color: var(--accent);
+
text-decoration: none;
+
display: inline;
+
}
+
footer a:hover {
+
text-decoration: underline;
+
}
+
</style>
+
</head>
+
<body>
+
<div>
+
<h1>404</h1>
+
<h2>Page not found</h2>
+
<p>The page you're looking for doesn't exist.</p>
+
<a href="/">← Back to home</a>
+
</div>
+
<footer>
+
Hosted on <a href="https://wisp.place" target="_blank" rel="noopener">wisp.place</a> - Made by <a href="https://bsky.app/profile/nekomimi.pet" target="_blank" rel="noopener">@nekomimi.pet</a>
+
</footer>
+
</body>
+
</html>`;
+
return html;
+
}
+
+
/**
+
* Generate directory listing HTML
+
*/
+
export function generateDirectoryListing(path: string, entries: Array<{name: string, isDirectory: boolean}>): string {
+
const title = path || 'Index';
+
+
// Sort: directories first, then files, alphabetically within each group
+
const sortedEntries = [...entries].sort((a, b) => {
+
if (a.isDirectory && !b.isDirectory) return -1;
+
if (!a.isDirectory && b.isDirectory) return 1;
+
return a.name.localeCompare(b.name);
+
});
+
+
const html = `<!DOCTYPE html>
+
<html>
+
<head>
+
<meta charset="utf-8">
+
<meta name="viewport" content="width=device-width, initial-scale=1">
+
<title>Index of /${path}</title>
+
<style>
+
@media (prefers-color-scheme: light) {
+
:root {
+
/* Warm beige background */
+
--background: oklch(0.90 0.012 35);
+
/* Very dark brown text */
+
--foreground: oklch(0.18 0.01 30);
+
--border: oklch(0.75 0.015 30);
+
/* Bright pink accent for links */
+
--accent: oklch(0.78 0.15 345);
+
/* Lavender for folders */
+
--folder: oklch(0.60 0.12 295);
+
--icon: oklch(0.28 0.01 30);
+
}
+
}
+
@media (prefers-color-scheme: dark) {
+
:root {
+
/* Slate violet background */
+
--background: oklch(0.23 0.015 285);
+
/* Light gray text */
+
--foreground: oklch(0.90 0.005 285);
+
/* Subtle borders */
+
--border: oklch(0.38 0.02 285);
+
/* Soft pink accent */
+
--accent: oklch(0.85 0.08 5);
+
/* Lavender for folders */
+
--folder: oklch(0.70 0.10 295);
+
--icon: oklch(0.85 0.005 285);
+
}
+
}
+
body {
+
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
+
background: var(--background);
+
color: var(--foreground);
+
padding: 2rem;
+
max-width: 800px;
+
margin: 0 auto;
+
}
+
h1 {
+
font-size: 1.5rem;
+
margin-bottom: 2rem;
+
padding-bottom: 0.5rem;
+
border-bottom: 1px solid var(--border);
+
}
+
ul {
+
list-style: none;
+
padding: 0;
+
}
+
li {
+
padding: 0.5rem 0;
+
border-bottom: 1px solid var(--border);
+
}
+
li:last-child {
+
border-bottom: none;
+
}
+
li a {
+
color: var(--accent);
+
text-decoration: none;
+
display: flex;
+
align-items: center;
+
gap: 0.75rem;
+
}
+
li a:hover {
+
text-decoration: underline;
+
}
+
.folder {
+
color: var(--folder);
+
font-weight: 600;
+
}
+
.file {
+
color: var(--accent);
+
}
+
.folder::before,
+
.file::before,
+
.parent::before {
+
content: "";
+
display: inline-block;
+
width: 1.25em;
+
height: 1.25em;
+
background-color: var(--icon);
+
flex-shrink: 0;
+
-webkit-mask-size: contain;
+
mask-size: contain;
+
-webkit-mask-repeat: no-repeat;
+
mask-repeat: no-repeat;
+
-webkit-mask-position: center;
+
mask-position: center;
+
}
+
.folder::before {
+
-webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M64 15v37a5.006 5.006 0 0 1-5 5H5a5.006 5.006 0 0 1-5-5V12a5.006 5.006 0 0 1 5-5h14.116a6.966 6.966 0 0 1 5.466 2.627l5 6.247A2.983 2.983 0 0 0 31.922 17H59a1 1 0 0 1 0 2H31.922a4.979 4.979 0 0 1-3.9-1.876l-5-6.247A4.976 4.976 0 0 0 19.116 9H5a3 3 0 0 0-3 3v40a3 3 0 0 0 3 3h54a3 3 0 0 0 3-3V15a3 3 0 0 0-3-3H30a1 1 0 0 1 0-2h29a5.006 5.006 0 0 1 5 5z"/></svg>');
+
mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M64 15v37a5.006 5.006 0 0 1-5 5H5a5.006 5.006 0 0 1-5-5V12a5.006 5.006 0 0 1 5-5h14.116a6.966 6.966 0 0 1 5.466 2.627l5 6.247A2.983 2.983 0 0 0 31.922 17H59a1 1 0 0 1 0 2H31.922a4.979 4.979 0 0 1-3.9-1.876l-5-6.247A4.976 4.976 0 0 0 19.116 9H5a3 3 0 0 0-3 3v40a3 3 0 0 0 3 3h54a3 3 0 0 0 3-3V15a3 3 0 0 0-3-3H30a1 1 0 0 1 0-2h29a5.006 5.006 0 0 1 5 5z"/></svg>');
+
}
+
.file::before {
+
-webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><g><path d="M18 8.28a.59.59 0 0 0-.13-.18l-4-3.9h-.05a.41.41 0 0 0-.15-.2.41.41 0 0 0-.19 0h-9a.5.5 0 0 0-.5.5v19a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5V8.43a.58.58 0 0 0 .02-.15zM16.3 8H14V5.69zM5 23V5h8v3.5a.49.49 0 0 0 .15.36.5.5 0 0 0 .35.14l3.5-.06V23z"/><path d="M20.5 1h-13a.5.5 0 0 0-.5.5V3a.5.5 0 0 0 1 0V2h12v18h-1a.5.5 0 0 0 0 1h1.5a.5.5 0 0 0 .5-.5v-19a.5.5 0 0 0-.5-.5z"/><path d="M7.5 8h3a.5.5 0 0 0 0-1h-3a.5.5 0 0 0 0 1zM7.5 11h4a.5.5 0 0 0 0-1h-4a.5.5 0 0 0 0 1zM13.5 13h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 16h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 19h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1z"/></g></svg>');
+
mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><g><path d="M18 8.28a.59.59 0 0 0-.13-.18l-4-3.9h-.05a.41.41 0 0 0-.15-.2.41.41 0 0 0-.19 0h-9a.5.5 0 0 0-.5.5v19a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5V8.43a.58.58 0 0 0 .02-.15zM16.3 8H14V5.69zM5 23V5h8v3.5a.49.49 0 0 0 .15.36.5.5 0 0 0 .35.14l3.5-.06V23z"/><path d="M20.5 1h-13a.5.5 0 0 0-.5.5V3a.5.5 0 0 0 1 0V2h12v18h-1a.5.5 0 0 0 0 1h1.5a.5.5 0 0 0 .5-.5v-19a.5.5 0 0 0-.5-.5z"/><path d="M7.5 8h3a.5.5 0 0 0 0-1h-3a.5.5 0 0 0 0 1zM7.5 11h4a.5.5 0 0 0 0-1h-4a.5.5 0 0 0 0 1zM13.5 13h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 16h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 19h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1z"/></g></svg>');
+
}
+
.parent::before {
+
-webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>');
+
mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>');
+
}
+
footer {
+
margin-top: 2rem;
+
padding-top: 1.5rem;
+
border-top: 1px solid var(--border);
+
text-align: center;
+
font-size: 0.875rem;
+
opacity: 0.7;
+
color: var(--foreground);
+
}
+
footer a {
+
color: var(--accent);
+
text-decoration: none;
+
display: inline;
+
}
+
footer a:hover {
+
text-decoration: underline;
+
}
+
</style>
+
</head>
+
<body>
+
<h1>Index of /${path}</h1>
+
<ul>
+
${path ? '<li><a href="../" class="parent">../</a></li>' : ''}
+
${sortedEntries.map(e =>
+
`<li><a href="${e.name}${e.isDirectory ? '/' : ''}" class="${e.isDirectory ? 'folder' : 'file'}">${e.name}${e.isDirectory ? '/' : ''}</a></li>`
+
).join('\n ')}
+
</ul>
+
<footer>
+
Hosted on <a href="https://wisp.place" target="_blank" rel="noopener">wisp.place</a> - Made by <a href="https://bsky.app/profile/nekomimi.pet" target="_blank" rel="noopener">@nekomimi.pet</a>
+
</footer>
+
</body>
+
</html>`;
+
return html;
+
}
+
+
/**
+
* Return a response indicating the site is being updated
+
*/
+
export function generateSiteUpdatingPage(): string {
+
const html = `<!DOCTYPE html>
+
<html>
+
<head>
+
<meta charset="utf-8">
+
<meta name="viewport" content="width=device-width, initial-scale=1">
+
<title>Site Updating</title>
+
<style>
+
@media (prefers-color-scheme: light) {
+
:root {
+
--background: oklch(0.90 0.012 35);
+
--foreground: oklch(0.18 0.01 30);
+
--primary: oklch(0.35 0.02 35);
+
--accent: oklch(0.78 0.15 345);
+
}
+
}
+
@media (prefers-color-scheme: dark) {
+
:root {
+
--background: oklch(0.23 0.015 285);
+
--foreground: oklch(0.90 0.005 285);
+
--primary: oklch(0.70 0.10 295);
+
--accent: oklch(0.85 0.08 5);
+
}
+
}
+
body {
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+
display: flex;
+
align-items: center;
+
justify-content: center;
+
min-height: 100vh;
+
margin: 0;
+
background: var(--background);
+
color: var(--foreground);
+
}
+
.container {
+
text-align: center;
+
padding: 2rem;
+
max-width: 500px;
+
}
+
h1 {
+
font-size: 2.5rem;
+
margin-bottom: 1rem;
+
font-weight: 600;
+
color: var(--primary);
+
}
+
p {
+
font-size: 1.25rem;
+
opacity: 0.8;
+
margin-bottom: 2rem;
+
color: var(--foreground);
+
}
+
.spinner {
+
border: 4px solid var(--accent);
+
border-radius: 50%;
+
border-top: 4px solid var(--primary);
+
width: 40px;
+
height: 40px;
+
animation: spin 1s linear infinite;
+
margin: 0 auto;
+
}
+
@keyframes spin {
+
0% { transform: rotate(0deg); }
+
100% { transform: rotate(360deg); }
+
}
+
</style>
+
<meta http-equiv="refresh" content="3">
+
</head>
+
<body>
+
<div class="container">
+
<h1>Site Updating</h1>
+
<p>This site is undergoing an update right now. Check back in a moment...</p>
+
<div class="spinner"></div>
+
</div>
+
</body>
+
</html>`;
+
+
return html;
+
}
+
+
/**
+
* Create a Response for site updating
+
*/
+
export function siteUpdatingResponse(): Response {
+
return new Response(generateSiteUpdatingPage(), {
+
status: 503,
+
headers: {
+
'Content-Type': 'text/html; charset=utf-8',
+
'Cache-Control': 'no-store, no-cache, must-revalidate',
+
'Retry-After': '3',
+
},
+
});
+
}
+
+96
apps/hosting-service/src/lib/request-utils.ts
···
···
+
/**
+
* Request utilities for validation and helper functions
+
*/
+
+
import type { Record as WispSettings } from '@wisp/lexicons/types/place/wisp/settings';
+
import { access } from 'fs/promises';
+
+
/**
+
* Default index file names to check for directory requests
+
* Will be checked in order until one is found
+
*/
+
export const DEFAULT_INDEX_FILES = ['index.html', 'index.htm'];
+
+
/**
+
* Get index files list from settings or use defaults
+
*/
+
export function getIndexFiles(settings: WispSettings | null): string[] {
+
if (settings?.indexFiles && settings.indexFiles.length > 0) {
+
return settings.indexFiles;
+
}
+
return DEFAULT_INDEX_FILES;
+
}
+
+
/**
+
* Match a file path against a glob pattern
+
* Supports * wildcard and basic path matching
+
*/
+
export function matchGlob(path: string, pattern: string): boolean {
+
// Normalize paths
+
const normalizedPath = path.startsWith('/') ? path : '/' + path;
+
const normalizedPattern = pattern.startsWith('/') ? pattern : '/' + pattern;
+
+
// Convert glob pattern to regex
+
const regexPattern = normalizedPattern
+
.replace(/\./g, '\\.')
+
.replace(/\*/g, '.*')
+
.replace(/\?/g, '.');
+
+
const regex = new RegExp('^' + regexPattern + '$');
+
return regex.test(normalizedPath);
+
}
+
+
/**
+
* Apply custom headers from settings to response headers
+
*/
+
export function applyCustomHeaders(headers: Record<string, string>, filePath: string, settings: WispSettings | null) {
+
if (!settings?.headers || settings.headers.length === 0) return;
+
+
for (const customHeader of settings.headers) {
+
// If path glob is specified, check if it matches
+
if (customHeader.path) {
+
if (!matchGlob(filePath, customHeader.path)) {
+
continue;
+
}
+
}
+
// Apply the header
+
headers[customHeader.name] = customHeader.value;
+
}
+
}
+
+
/**
+
* Validate site name (rkey) to prevent injection attacks
+
* Must match AT Protocol rkey format
+
*/
+
export function isValidRkey(rkey: string): boolean {
+
if (!rkey || typeof rkey !== 'string') return false;
+
if (rkey.length < 1 || rkey.length > 512) return false;
+
if (rkey === '.' || rkey === '..') return false;
+
if (rkey.includes('/') || rkey.includes('\\') || rkey.includes('\0')) return false;
+
const validRkeyPattern = /^[a-zA-Z0-9._~:-]+$/;
+
return validRkeyPattern.test(rkey);
+
}
+
+
/**
+
* Async file existence check
+
*/
+
export async function fileExists(path: string): Promise<boolean> {
+
try {
+
await access(path);
+
return true;
+
} catch {
+
return false;
+
}
+
}
+
+
/**
+
* Extract and normalize headers from request
+
*/
+
export function extractHeaders(rawHeaders: Headers): Record<string, string> {
+
const headers: Record<string, string> = {};
+
rawHeaders.forEach((value, key) => {
+
headers[key.toLowerCase()] = value;
+
});
+
return headers;
+
}
+
+79
apps/hosting-service/src/lib/site-cache.ts
···
···
+
/**
+
* Site caching management utilities
+
*/
+
+
import { createLogger } from '@wisp/observability';
+
import { fetchSiteRecord, getPdsForDid, downloadAndCacheSite, isCached } from './utils';
+
import { markSiteAsBeingCached, unmarkSiteAsBeingCached } from './cache';
+
import type { RedirectRule } from './redirects';
+
+
const logger = createLogger('hosting-service');
+
+
// Cache for redirect rules (per site)
+
const redirectRulesCache = new Map<string, RedirectRule[]>();
+
+
/**
+
* Clear redirect rules cache for a specific site
+
* Should be called when a site is updated/recached
+
*/
+
export function clearRedirectRulesCache(did: string, rkey: string) {
+
const cacheKey = `${did}:${rkey}`;
+
redirectRulesCache.delete(cacheKey);
+
}
+
+
/**
+
* Get redirect rules from cache
+
*/
+
export function getRedirectRulesFromCache(did: string, rkey: string): RedirectRule[] | undefined {
+
const cacheKey = `${did}:${rkey}`;
+
return redirectRulesCache.get(cacheKey);
+
}
+
+
/**
+
* Set redirect rules in cache
+
*/
+
export function setRedirectRulesInCache(did: string, rkey: string, rules: RedirectRule[]) {
+
const cacheKey = `${did}:${rkey}`;
+
redirectRulesCache.set(cacheKey, rules);
+
}
+
+
/**
+
* Helper to ensure site is cached
+
* Returns true if site is successfully cached, false otherwise
+
*/
+
export async function ensureSiteCached(did: string, rkey: string): Promise<boolean> {
+
if (isCached(did, rkey)) {
+
return true;
+
}
+
+
// Fetch and cache the site
+
const siteData = await fetchSiteRecord(did, rkey);
+
if (!siteData) {
+
logger.error('Site record not found', null, { did, rkey });
+
return false;
+
}
+
+
const pdsEndpoint = await getPdsForDid(did);
+
if (!pdsEndpoint) {
+
logger.error('PDS not found for DID', null, { did });
+
return false;
+
}
+
+
// Mark site as being cached to prevent serving stale content during update
+
markSiteAsBeingCached(did, rkey);
+
+
try {
+
await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid);
+
// Clear redirect rules cache since the site was updated
+
clearRedirectRulesCache(did, rkey);
+
logger.info('Site cached successfully', { did, rkey });
+
return true;
+
} catch (err) {
+
logger.error('Failed to cache site', err, { did, rkey });
+
return false;
+
} finally {
+
// Always unmark, even if caching fails
+
unmarkSiteAsBeingCached(did, rkey);
+
}
+
}
+
+234
apps/hosting-service/src/server.ts
···
···
+
/**
+
* Main server entry point for the hosting service
+
* Handles routing and request dispatching
+
*/
+
+
import { Hono } from 'hono';
+
import { cors } from 'hono/cors';
+
import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db';
+
import { resolveDid } from './lib/utils';
+
import { logCollector, errorTracker, metricsCollector } from '@wisp/observability';
+
import { observabilityMiddleware, observabilityErrorHandler } from '@wisp/observability/middleware/hono';
+
import { sanitizePath } from '@wisp/fs-utils';
+
import { isSiteBeingCached } from './lib/cache';
+
import { isValidRkey, extractHeaders } from './lib/request-utils';
+
import { siteUpdatingResponse } from './lib/page-generators';
+
import { ensureSiteCached } from './lib/site-cache';
+
import { serveFromCache, serveFromCacheWithRewrite } from './lib/file-serving';
+
+
const BASE_HOST = process.env.BASE_HOST || 'wisp.place';
+
+
const app = new Hono();
+
+
// Add CORS middleware - allow all origins for static site hosting
+
app.use('*', cors({
+
origin: '*',
+
allowMethods: ['GET', 'HEAD', 'OPTIONS'],
+
allowHeaders: ['Content-Type', 'Authorization'],
+
exposeHeaders: ['Content-Length', 'Content-Type', 'Content-Encoding', 'Cache-Control'],
+
maxAge: 86400, // 24 hours
+
credentials: false,
+
}));
+
+
// Add observability middleware
+
app.use('*', observabilityMiddleware('hosting-service'));
+
+
// Error handler
+
app.onError(observabilityErrorHandler('hosting-service'));
+
+
// Main site serving route
+
app.get('/*', async (c) => {
+
const url = new URL(c.req.url);
+
const hostname = c.req.header('host') || '';
+
const rawPath = url.pathname.replace(/^\//, '');
+
const path = sanitizePath(rawPath);
+
+
// Check if this is sites.wisp.place subdomain (strip port for comparison)
+
const hostnameWithoutPort = hostname.split(':')[0];
+
if (hostnameWithoutPort === `sites.${BASE_HOST}`) {
+
// Sanitize the path FIRST to prevent path traversal
+
const sanitizedFullPath = sanitizePath(rawPath);
+
+
// Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html
+
const pathParts = sanitizedFullPath.split('/');
+
if (pathParts.length < 2) {
+
return c.text('Invalid path format. Expected: /identifier/sitename/path', 400);
+
}
+
+
const identifier = pathParts[0];
+
const site = pathParts[1];
+
const filePath = pathParts.slice(2).join('/');
+
+
// Additional validation: identifier must be a valid DID or handle format
+
if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) {
+
return c.text('Invalid identifier', 400);
+
}
+
+
// Validate site parameter exists
+
if (!site) {
+
return c.text('Site name required', 400);
+
}
+
+
// Validate site name (rkey)
+
if (!isValidRkey(site)) {
+
return c.text('Invalid site name', 400);
+
}
+
+
// Resolve identifier to DID
+
const did = await resolveDid(identifier);
+
if (!did) {
+
return c.text('Invalid identifier', 400);
+
}
+
+
// Check if site is currently being cached - return updating response early
+
if (isSiteBeingCached(did, site)) {
+
return siteUpdatingResponse();
+
}
+
+
// Ensure site is cached
+
const cached = await ensureSiteCached(did, site);
+
if (!cached) {
+
return c.text('Site not found', 404);
+
}
+
+
// Serve with HTML path rewriting to handle absolute paths
+
const basePath = `/${identifier}/${site}/`;
+
const headers = extractHeaders(c.req.raw.headers);
+
return serveFromCacheWithRewrite(did, site, filePath, basePath, c.req.url, headers);
+
}
+
+
// Check if this is a DNS hash subdomain
+
const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/);
+
if (dnsMatch) {
+
const hash = dnsMatch[1];
+
const baseDomain = dnsMatch[2];
+
+
if (!hash) {
+
return c.text('Invalid DNS hash', 400);
+
}
+
+
if (baseDomain !== BASE_HOST) {
+
return c.text('Invalid base domain', 400);
+
}
+
+
const customDomain = await getCustomDomainByHash(hash);
+
if (!customDomain) {
+
return c.text('Custom domain not found or not verified', 404);
+
}
+
+
if (!customDomain.rkey) {
+
return c.text('Domain not mapped to a site', 404);
+
}
+
+
const rkey = customDomain.rkey;
+
if (!isValidRkey(rkey)) {
+
return c.text('Invalid site configuration', 500);
+
}
+
+
// Check if site is currently being cached - return updating response early
+
if (isSiteBeingCached(customDomain.did, rkey)) {
+
return siteUpdatingResponse();
+
}
+
+
const cached = await ensureSiteCached(customDomain.did, rkey);
+
if (!cached) {
+
return c.text('Site not found', 404);
+
}
+
+
const headers = extractHeaders(c.req.raw.headers);
+
return serveFromCache(customDomain.did, rkey, path, c.req.url, headers);
+
}
+
+
// Route 2: Registered subdomains - /*.wisp.place/*
+
if (hostname.endsWith(`.${BASE_HOST}`)) {
+
const domainInfo = await getWispDomain(hostname);
+
if (!domainInfo) {
+
return c.text('Subdomain not registered', 404);
+
}
+
+
if (!domainInfo.rkey) {
+
return c.text('Domain not mapped to a site', 404);
+
}
+
+
const rkey = domainInfo.rkey;
+
if (!isValidRkey(rkey)) {
+
return c.text('Invalid site configuration', 500);
+
}
+
+
// Check if site is currently being cached - return updating response early
+
if (isSiteBeingCached(domainInfo.did, rkey)) {
+
return siteUpdatingResponse();
+
}
+
+
const cached = await ensureSiteCached(domainInfo.did, rkey);
+
if (!cached) {
+
return c.text('Site not found', 404);
+
}
+
+
const headers = extractHeaders(c.req.raw.headers);
+
return serveFromCache(domainInfo.did, rkey, path, c.req.url, headers);
+
}
+
+
// Route 1: Custom domains - /*
+
const customDomain = await getCustomDomain(hostname);
+
if (!customDomain) {
+
return c.text('Custom domain not found or not verified', 404);
+
}
+
+
if (!customDomain.rkey) {
+
return c.text('Domain not mapped to a site', 404);
+
}
+
+
const rkey = customDomain.rkey;
+
if (!isValidRkey(rkey)) {
+
return c.text('Invalid site configuration', 500);
+
}
+
+
// Check if site is currently being cached - return updating response early
+
if (isSiteBeingCached(customDomain.did, rkey)) {
+
return siteUpdatingResponse();
+
}
+
+
const cached = await ensureSiteCached(customDomain.did, rkey);
+
if (!cached) {
+
return c.text('Site not found', 404);
+
}
+
+
const headers = extractHeaders(c.req.raw.headers);
+
return serveFromCache(customDomain.did, rkey, path, c.req.url, headers);
+
});
+
+
// Internal observability endpoints (for admin panel)
+
app.get('/__internal__/observability/logs', (c) => {
+
const query = c.req.query();
+
const filter: any = {};
+
if (query.level) filter.level = query.level;
+
if (query.service) filter.service = query.service;
+
if (query.search) filter.search = query.search;
+
if (query.eventType) filter.eventType = query.eventType;
+
if (query.limit) filter.limit = parseInt(query.limit as string);
+
return c.json({ logs: logCollector.getLogs(filter) });
+
});
+
+
app.get('/__internal__/observability/errors', (c) => {
+
const query = c.req.query();
+
const filter: any = {};
+
if (query.service) filter.service = query.service;
+
if (query.limit) filter.limit = parseInt(query.limit as string);
+
return c.json({ errors: errorTracker.getErrors(filter) });
+
});
+
+
app.get('/__internal__/observability/metrics', (c) => {
+
const query = c.req.query();
+
const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000;
+
const stats = metricsCollector.getStats('hosting-service', timeWindow);
+
return c.json({ stats, timeWindow });
+
});
+
+
app.get('/__internal__/observability/cache', async (c) => {
+
const { getCacheStats } = await import('./lib/cache');
+
const stats = getCacheStats();
+
return c.json({ cache: stats });
+
});
+
+
export default app;
+67
apps/main-app/package.json
···
···
+
{
+
"name": "@wisp/main-app",
+
"version": "1.0.50",
+
"private": true,
+
"scripts": {
+
"test": "bun test",
+
"dev": "bun run --watch src/index.ts",
+
"start": "bun run src/index.ts",
+
"build": "bun build --compile --target bun --outfile server src/index.ts",
+
"screenshot": "bun run scripts/screenshot-sites.ts"
+
},
+
"dependencies": {
+
"@wisp/lexicons": "workspace:*",
+
"@wisp/constants": "workspace:*",
+
"@wisp/observability": "workspace:*",
+
"@wisp/atproto-utils": "workspace:*",
+
"@wisp/database": "workspace:*",
+
"@wisp/fs-utils": "workspace:*",
+
"@atproto/api": "^0.17.3",
+
"@atproto/lex-cli": "^0.9.5",
+
"@atproto/oauth-client-node": "^0.3.9",
+
"@atproto/xrpc-server": "^0.9.5",
+
"@elysiajs/cors": "^1.4.0",
+
"@elysiajs/eden": "^1.4.3",
+
"@elysiajs/openapi": "^1.4.11",
+
"@elysiajs/opentelemetry": "^1.4.6",
+
"@elysiajs/static": "^1.4.2",
+
"@radix-ui/react-checkbox": "^1.3.3",
+
"@radix-ui/react-dialog": "^1.1.15",
+
"@radix-ui/react-label": "^2.1.7",
+
"@radix-ui/react-radio-group": "^1.3.8",
+
"@radix-ui/react-slot": "^1.2.3",
+
"@radix-ui/react-tabs": "^1.1.13",
+
"@tanstack/react-query": "^5.90.2",
+
"actor-typeahead": "^0.1.1",
+
"atproto-ui": "^0.11.3",
+
"class-variance-authority": "^0.7.1",
+
"clsx": "^2.1.1",
+
"elysia": "latest",
+
"iron-session": "^8.0.4",
+
"lucide-react": "^0.546.0",
+
"multiformats": "^13.4.1",
+
"prismjs": "^1.30.0",
+
"react": "^19.2.0",
+
"react-dom": "^19.2.0",
+
"tailwind-merge": "^3.3.1",
+
"tailwindcss": "4",
+
"tw-animate-css": "^1.4.0",
+
"typescript": "^5.9.3",
+
"zlib": "^1.0.5"
+
},
+
"devDependencies": {
+
"@types/react": "^19.2.2",
+
"@types/react-dom": "^19.2.1",
+
"bun-plugin-tailwind": "^0.1.2",
+
"bun-types": "latest",
+
"esbuild": "0.26.0",
+
"playwright": "^1.49.0"
+
},
+
"module": "src/index.js",
+
"trustedDependencies": [
+
"bun",
+
"cbor-extract",
+
"core-js",
+
"protobufjs"
+
]
+
}
+9
apps/main-app/src/lib/logger.ts
···
···
+
/**
+
* Main app logger using @wisp/observability
+
*
+
* Note: This file is kept for backward compatibility.
+
* New code should import createLogger from @wisp/observability directly.
+
*/
+
import { createLogger } from '@wisp/observability'
+
+
export const logger = createLogger('main-app')
+14
apps/main-app/tsconfig.json
···
···
+
{
+
"extends": "../../tsconfig.json",
+
"compilerOptions": {
+
"baseUrl": ".",
+
"paths": {
+
"@server": ["./src/index.ts"],
+
"@server/*": ["./src/*"],
+
"@public/*": ["./public/*"],
+
"@wisp/*": ["../../packages/@wisp/*/src"]
+
}
+
},
+
"include": ["src/**/*", "public/**/*", "scripts/**/*"],
+
"exclude": ["node_modules"]
+
}
+291 -9
bun.lock
···
"workspaces": {
"": {
"name": "elysia-static",
"dependencies": {
"@atproto/api": "^0.17.3",
"@atproto/lex-cli": "^0.9.5",
···
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@tanstack/react-query": "^5.90.2",
"actor-typeahead": "^0.1.1",
"atproto-ui": "^0.11.3",
"class-variance-authority": "^0.7.1",
···
"playwright": "^1.49.0",
},
},
},
"trustedDependencies": [
"core-js",
···
"@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="],
-
"@atproto/api": ["@atproto/api@0.17.7", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "@atproto/xrpc": "^0.7.5", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-V+OJBZq9chcrD21xk1bUa6oc5DSKfQj5DmUPf5rmZncqL1w9ZEbS38H5cMyqqdhfgo2LWeDRdZHD0rvNyJsIaw=="],
"@atproto/common": ["@atproto/common@0.4.12", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@ipld/dag-cbor": "^7.0.3", "cbor-x": "^1.5.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "pino": "^8.21.0" } }, "sha512-NC+TULLQiqs6MvNymhQS5WDms3SlbIKGLf4n33tpftRJcalh507rI+snbcUb7TLIkKw7VO17qMqxEXtIdd5auQ=="],
···
"@atproto/crypto": ["@atproto/crypto@0.4.4", "", { "dependencies": { "@noble/curves": "^1.7.0", "@noble/hashes": "^1.6.1", "uint8arrays": "3.0.0" } }, "sha512-Yq9+crJ7WQl7sxStVpHgie5Z51R05etaK9DLWYG/7bR5T4bhdcIgF6IfklLShtZwLYdVVj+K15s0BqW9a8PSDA=="],
"@atproto/did": ["@atproto/did@0.2.1", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-1i5BTU2GnBaaeYWhxUOnuEKFVq9euT5+dQPFabHpa927BlJ54PmLGyBBaOI7/NbLmN5HWwBa18SBkMpg3jGZRA=="],
"@atproto/jwk": ["@atproto/jwk@0.6.0", "", { "dependencies": { "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw=="],
···
"@atproto/jwk-webcrypto": ["@atproto/jwk-webcrypto@0.2.0", "", { "dependencies": { "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "zod": "^3.23.8" } }, "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg=="],
"@atproto/lex-cli": ["@atproto/lex-cli@0.9.6", "", { "dependencies": { "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "chalk": "^4.1.2", "commander": "^9.4.0", "prettier": "^3.2.5", "ts-morph": "^24.0.0", "yesno": "^0.4.0", "zod": "^3.23.8" }, "bin": { "lex": "dist/index.js" } }, "sha512-EedEKmURoSP735YwSDHsFrLOhZ4P2it8goCHv5ApWi/R9DFpOKOpmYfIXJ9MAprK8cw+yBnjDJbzpLJy7UXlTg=="],
-
"@atproto/lexicon": ["@atproto/lexicon@0.5.1", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/syntax": "^0.4.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A=="],
"@atproto/oauth-client": ["@atproto/oauth-client@0.5.8", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.2", "@atproto-labs/fetch": "0.2.3", "@atproto-labs/handle-resolver": "0.3.2", "@atproto-labs/identity-resolver": "0.3.2", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "@atproto/oauth-types": "0.5.0", "@atproto/xrpc": "0.7.5", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-7YEym6d97+Dd73qGdkQTXi5La8xvCQxwRUDzzlR/NVAARa9a4YP7MCmqBJVeP2anT0By+DSAPyPDLTsxcjIcCg=="],
···
"@atproto/oauth-types": ["@atproto/oauth-types@0.5.0", "", { "dependencies": { "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "zod": "^3.23.8" } }, "sha512-33xz7HcXhbl+XRqbIMVu3GE02iK1nKe2oMWENASsfZEYbCz2b9ZOarOFuwi7g4LKqpGowGp0iRKsQHFcq4SDaQ=="],
"@atproto/syntax": ["@atproto/syntax@0.4.1", "", {}, "sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw=="],
"@atproto/xrpc": ["@atproto/xrpc@0.7.5", "", { "dependencies": { "@atproto/lexicon": "^0.5.1", "zod": "^3.23.8" } }, "sha512-MUYNn5d2hv8yVegRL0ccHvTHAVj5JSnW07bkbiaz96UH45lvYNRVwt44z+yYVnb0/mvBzyD3/ZQ55TRGt7fHkA=="],
···
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.1", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-sPxgEWtPUR3EnRJCEtbGZG2iX8LQDUls2wUS3o27jg07KqJFMq6YDeWvMo1wfpmy3rqRdS0rivpLwhqQtEyCuQ=="],
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="],
"@ipld/dag-cbor": ["@ipld/dag-cbor@7.0.3", "", { "dependencies": { "cborg": "^1.6.0", "multiformats": "^9.5.4" } }, "sha512-1VVh2huHsuohdXC1bGJNE8WR72slZ9XE2T3wbBBq31dm7ZBatmKLLxrB+XAqafxfRFjv08RZmj/W/ZqaM13AuA=="],
···
"@ts-morph/common": ["@ts-morph/common@0.25.0", "", { "dependencies": { "minimatch": "^9.0.4", "path-browserify": "^1.0.1", "tinyglobby": "^0.2.9" } }, "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg=="],
-
"@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
···
"@types/shimmer": ["@types/shimmer@1.2.0", "", {}, "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
···
"bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="],
-
"bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
···
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
-
"elysia": ["elysia@1.4.15", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.2", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-RaDqqZdLuC4UJetfVRQ4Z5aVpGgEtQ+pZnsbI4ZzEaf3l/MzuHcqSVoL/Fue3d6qE4RV9HMB2rAZaHyPIxkyzg=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
···
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
-
"exact-mirror": ["exact-mirror@0.2.2", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-CrGe+4QzHZlnrXZVlo/WbUZ4qQZq8C0uATQVGVgXIrNXgHDBBNFD1VRfssRA2C9t3RYvh3MadZSdg2Wy7HBoQA=="],
"express": ["express@4.21.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "0.19.0", "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA=="],
···
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
···
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
···
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
-
"ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="],
"iron-session": ["iron-session@8.0.4", "", { "dependencies": { "cookie": "^0.7.2", "iron-webcrypto": "^1.2.1", "uncrypto": "^0.1.3" } }, "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA=="],
···
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
···
"playwright-core": ["playwright-core@1.56.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ=="],
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
···
"require-in-the-middle": ["require-in-the-middle@7.5.2", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3", "resolve": "^1.22.8" } }, "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
···
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
···
"undici": ["undici@6.22.0", "", {}, "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw=="],
-
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
···
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
···
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@atproto/api/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
"@atproto/common/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
"@atproto/common-web/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
"@atproto/jwk/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
"@atproto/lexicon/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
"@atproto/oauth-client/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
"@ipld/dag-cbor/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
···
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@tokenizer/inflate/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"express/cookie": ["cookie@0.7.1", "", {}, "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="],
"iron-session/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
-
"proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"require-in-the-middle/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
···
"send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"uint8arrays/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
"@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"require-in-the-middle/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
}
}
···
"workspaces": {
"": {
"name": "elysia-static",
+
},
+
"apps/hosting-service": {
+
"name": "wisp-hosting-service",
+
"version": "1.0.0",
+
"dependencies": {
+
"@atproto/api": "^0.17.4",
+
"@atproto/identity": "^0.4.9",
+
"@atproto/lexicon": "^0.5.1",
+
"@atproto/sync": "^0.1.36",
+
"@atproto/xrpc": "^0.7.5",
+
"@hono/node-server": "^1.19.6",
+
"@wisp/atproto-utils": "workspace:*",
+
"@wisp/constants": "workspace:*",
+
"@wisp/database": "workspace:*",
+
"@wisp/fs-utils": "workspace:*",
+
"@wisp/lexicons": "workspace:*",
+
"@wisp/observability": "workspace:*",
+
"@wisp/safe-fetch": "workspace:*",
+
"hono": "^4.10.4",
+
"mime-types": "^2.1.35",
+
"multiformats": "^13.4.1",
+
"postgres": "^3.4.5",
+
},
+
"devDependencies": {
+
"@types/bun": "^1.3.1",
+
"@types/mime-types": "^2.1.4",
+
"@types/node": "^22.10.5",
+
"tsx": "^4.19.2",
+
},
+
},
+
"apps/main-app": {
+
"name": "@wisp/main-app",
+
"version": "1.0.50",
"dependencies": {
"@atproto/api": "^0.17.3",
"@atproto/lex-cli": "^0.9.5",
···
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@tanstack/react-query": "^5.90.2",
+
"@wisp/atproto-utils": "workspace:*",
+
"@wisp/constants": "workspace:*",
+
"@wisp/database": "workspace:*",
+
"@wisp/fs-utils": "workspace:*",
+
"@wisp/lexicons": "workspace:*",
+
"@wisp/observability": "workspace:*",
"actor-typeahead": "^0.1.1",
"atproto-ui": "^0.11.3",
"class-variance-authority": "^0.7.1",
···
"playwright": "^1.49.0",
},
},
+
"packages/@wisp/atproto-utils": {
+
"name": "@wisp/atproto-utils",
+
"version": "1.0.0",
+
"dependencies": {
+
"@atproto/api": "^0.14.1",
+
"@wisp/lexicons": "workspace:*",
+
"multiformats": "^13.3.1",
+
},
+
},
+
"packages/@wisp/constants": {
+
"name": "@wisp/constants",
+
"version": "1.0.0",
+
},
+
"packages/@wisp/database": {
+
"name": "@wisp/database",
+
"version": "1.0.0",
+
"dependencies": {
+
"postgres": "^3.4.5",
+
},
+
"peerDependencies": {
+
"bun": "^1.0.0",
+
},
+
"optionalPeers": [
+
"bun",
+
],
+
},
+
"packages/@wisp/fs-utils": {
+
"name": "@wisp/fs-utils",
+
"version": "1.0.0",
+
"dependencies": {
+
"@atproto/api": "^0.14.1",
+
"@wisp/lexicons": "workspace:*",
+
},
+
},
+
"packages/@wisp/lexicons": {
+
"name": "@wisp/lexicons",
+
"version": "1.0.0",
+
"dependencies": {
+
"@atproto/lexicon": "^0.5.1",
+
"@atproto/xrpc-server": "^0.9.5",
+
},
+
"devDependencies": {
+
"@atproto/lex-cli": "^0.9.5",
+
},
+
},
+
"packages/@wisp/observability": {
+
"name": "@wisp/observability",
+
"version": "1.0.0",
+
"peerDependencies": {
+
"hono": "^4.0.0",
+
},
+
"optionalPeers": [
+
"hono",
+
],
+
},
+
"packages/@wisp/safe-fetch": {
+
"name": "@wisp/safe-fetch",
+
"version": "1.0.0",
+
},
},
"trustedDependencies": [
"core-js",
···
"@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="],
+
"@atproto/api": ["@atproto/api@0.14.22", "", { "dependencies": { "@atproto/common-web": "^0.4.1", "@atproto/lexicon": "^0.4.10", "@atproto/syntax": "^0.4.0", "@atproto/xrpc": "^0.6.12", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-ziXPau+sUdFovObSnsoN7JbOmUw1C5e5L28/yXf3P8vbEnSS3HVVGD1jYcscBYY34xQqi4bVDpwMYx/4yRsTuQ=="],
"@atproto/common": ["@atproto/common@0.4.12", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@ipld/dag-cbor": "^7.0.3", "cbor-x": "^1.5.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "pino": "^8.21.0" } }, "sha512-NC+TULLQiqs6MvNymhQS5WDms3SlbIKGLf4n33tpftRJcalh507rI+snbcUb7TLIkKw7VO17qMqxEXtIdd5auQ=="],
···
"@atproto/crypto": ["@atproto/crypto@0.4.4", "", { "dependencies": { "@noble/curves": "^1.7.0", "@noble/hashes": "^1.6.1", "uint8arrays": "3.0.0" } }, "sha512-Yq9+crJ7WQl7sxStVpHgie5Z51R05etaK9DLWYG/7bR5T4bhdcIgF6IfklLShtZwLYdVVj+K15s0BqW9a8PSDA=="],
"@atproto/did": ["@atproto/did@0.2.1", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-1i5BTU2GnBaaeYWhxUOnuEKFVq9euT5+dQPFabHpa927BlJ54PmLGyBBaOI7/NbLmN5HWwBa18SBkMpg3jGZRA=="],
+
+
"@atproto/identity": ["@atproto/identity@0.4.10", "", { "dependencies": { "@atproto/common-web": "^0.4.4", "@atproto/crypto": "^0.4.4" } }, "sha512-nQbzDLXOhM8p/wo0cTh5DfMSOSHzj6jizpodX37LJ4S1TZzumSxAjHEZa5Rev3JaoD5uSWMVE0MmKEGWkPPvfQ=="],
"@atproto/jwk": ["@atproto/jwk@0.6.0", "", { "dependencies": { "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw=="],
···
"@atproto/jwk-webcrypto": ["@atproto/jwk-webcrypto@0.2.0", "", { "dependencies": { "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "zod": "^3.23.8" } }, "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg=="],
+
"@atproto/lex-cbor": ["@atproto/lex-cbor@0.0.1", "", { "dependencies": { "@atproto/lex-data": "0.0.1", "multiformats": "^9.9.0", "tslib": "^2.8.1" } }, "sha512-GCgowcC041tYmsoIxalIECJq4ZRHgREk6lFa4BzNRUZarMqwz57YF/7eUlo2Q6hoaMUL7Bjr6FvXwcZFaKrhvA=="],
+
"@atproto/lex-cli": ["@atproto/lex-cli@0.9.6", "", { "dependencies": { "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "chalk": "^4.1.2", "commander": "^9.4.0", "prettier": "^3.2.5", "ts-morph": "^24.0.0", "yesno": "^0.4.0", "zod": "^3.23.8" }, "bin": { "lex": "dist/index.js" } }, "sha512-EedEKmURoSP735YwSDHsFrLOhZ4P2it8goCHv5ApWi/R9DFpOKOpmYfIXJ9MAprK8cw+yBnjDJbzpLJy7UXlTg=="],
+
"@atproto/lex-data": ["@atproto/lex-data@0.0.1", "", { "dependencies": { "@atproto/syntax": "0.4.1", "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-DrS/8cQcQs3s5t9ELAFNtyDZ8/PdiCx47ALtFEP2GnX2uCBHZRkqWG7xmu6ehjc787nsFzZBvlnz3T/gov5fGA=="],
+
+
"@atproto/lex-json": ["@atproto/lex-json@0.0.1", "", { "dependencies": { "@atproto/lex-data": "0.0.1", "tslib": "^2.8.1" } }, "sha512-ivcF7+pDRuD/P97IEKQ/9TruunXj0w58Khvwk3M6psaI5eZT6LRsRZ4cWcKaXiFX4SHnjy+x43g0f7pPtIsERg=="],
+
+
"@atproto/lexicon": ["@atproto/lexicon@0.5.2", "", { "dependencies": { "@atproto/common-web": "^0.4.4", "@atproto/syntax": "^0.4.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-lRmJgMA8f5j7VB5Iu5cp188ald5FuI4FlmZ7nn6EBrk1dgOstWVrI5Ft6K3z2vjyLZRG6nzknlsw+tDP63p7bQ=="],
"@atproto/oauth-client": ["@atproto/oauth-client@0.5.8", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.2", "@atproto-labs/fetch": "0.2.3", "@atproto-labs/handle-resolver": "0.3.2", "@atproto-labs/identity-resolver": "0.3.2", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "@atproto/oauth-types": "0.5.0", "@atproto/xrpc": "0.7.5", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-7YEym6d97+Dd73qGdkQTXi5La8xvCQxwRUDzzlR/NVAARa9a4YP7MCmqBJVeP2anT0By+DSAPyPDLTsxcjIcCg=="],
···
"@atproto/oauth-types": ["@atproto/oauth-types@0.5.0", "", { "dependencies": { "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "zod": "^3.23.8" } }, "sha512-33xz7HcXhbl+XRqbIMVu3GE02iK1nKe2oMWENASsfZEYbCz2b9ZOarOFuwi7g4LKqpGowGp0iRKsQHFcq4SDaQ=="],
+
"@atproto/repo": ["@atproto/repo@0.8.11", "", { "dependencies": { "@atproto/common": "^0.5.0", "@atproto/common-web": "^0.4.4", "@atproto/crypto": "^0.4.4", "@atproto/lexicon": "^0.5.2", "@ipld/dag-cbor": "^7.0.0", "multiformats": "^9.9.0", "uint8arrays": "3.0.0", "varint": "^6.0.0", "zod": "^3.23.8" } }, "sha512-b/WCu5ITws4ILHoXiZz0XXB5U9C08fUVzkBQDwpnme62GXv8gUaAPL/ttG61OusW09ARwMMQm4vxoP0hTFg+zA=="],
+
+
"@atproto/sync": ["@atproto/sync@0.1.38", "", { "dependencies": { "@atproto/common": "^0.5.0", "@atproto/identity": "^0.4.10", "@atproto/lexicon": "^0.5.2", "@atproto/repo": "^0.8.11", "@atproto/syntax": "^0.4.1", "@atproto/xrpc-server": "^0.10.0", "multiformats": "^9.9.0", "p-queue": "^6.6.2", "ws": "^8.12.0" } }, "sha512-2rE0SM21Nk4hWw/XcIYFnzlWO6/gBg8mrzuWbOvDhD49sA/wW4zyjaHZ5t1gvk28/SLok2VZiIR8nYBdbf7F5Q=="],
+
"@atproto/syntax": ["@atproto/syntax@0.4.1", "", {}, "sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw=="],
+
+
"@atproto/ws-client": ["@atproto/ws-client@0.0.3", "", { "dependencies": { "@atproto/common": "^0.5.0", "ws": "^8.12.0" } }, "sha512-eKqkTWBk6zuMY+6gs02eT7mS8Btewm8/qaL/Dp00NDCqpNC+U59MWvQsOWT3xkNGfd9Eip+V6VI4oyPvAfsfTA=="],
"@atproto/xrpc": ["@atproto/xrpc@0.7.5", "", { "dependencies": { "@atproto/lexicon": "^0.5.1", "zod": "^3.23.8" } }, "sha512-MUYNn5d2hv8yVegRL0ccHvTHAVj5JSnW07bkbiaz96UH45lvYNRVwt44z+yYVnb0/mvBzyD3/ZQ55TRGt7fHkA=="],
···
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.1", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-sPxgEWtPUR3EnRJCEtbGZG2iX8LQDUls2wUS3o27jg07KqJFMq6YDeWvMo1wfpmy3rqRdS0rivpLwhqQtEyCuQ=="],
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="],
+
+
"@hono/node-server": ["@hono/node-server@1.19.6", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw=="],
"@ipld/dag-cbor": ["@ipld/dag-cbor@7.0.3", "", { "dependencies": { "cborg": "^1.6.0", "multiformats": "^9.5.4" } }, "sha512-1VVh2huHsuohdXC1bGJNE8WR72slZ9XE2T3wbBBq31dm7ZBatmKLLxrB+XAqafxfRFjv08RZmj/W/ZqaM13AuA=="],
···
"@ts-morph/common": ["@ts-morph/common@0.25.0", "", { "dependencies": { "minimatch": "^9.0.4", "path-browserify": "^1.0.1", "tinyglobby": "^0.2.9" } }, "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg=="],
+
"@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="],
+
+
"@types/mime-types": ["@types/mime-types@2.1.4", "", {}, "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w=="],
+
+
"@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="],
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
···
"@types/shimmer": ["@types/shimmer@1.2.0", "", {}, "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg=="],
+
"@wisp/atproto-utils": ["@wisp/atproto-utils@workspace:packages/@wisp/atproto-utils"],
+
+
"@wisp/constants": ["@wisp/constants@workspace:packages/@wisp/constants"],
+
+
"@wisp/database": ["@wisp/database@workspace:packages/@wisp/database"],
+
+
"@wisp/fs-utils": ["@wisp/fs-utils@workspace:packages/@wisp/fs-utils"],
+
+
"@wisp/lexicons": ["@wisp/lexicons@workspace:packages/@wisp/lexicons"],
+
+
"@wisp/main-app": ["@wisp/main-app@workspace:apps/main-app"],
+
+
"@wisp/observability": ["@wisp/observability@workspace:packages/@wisp/observability"],
+
+
"@wisp/safe-fetch": ["@wisp/safe-fetch@workspace:packages/@wisp/safe-fetch"],
+
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
···
"bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="],
+
"bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
···
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
+
"elysia": ["elysia@1.4.16", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.3", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-KZtKN160/bdWVKg2hEgyoNXY8jRRquc+m6PboyisaLZL891I+Ufb7Ja6lDAD7vMQur8sLEWIcidZOzj5lWw9UA=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
···
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
+
+
"eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
+
"exact-mirror": ["exact-mirror@0.2.3", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-aLdARfO0W0ntufjDyytUJQMbNXoB9g+BbA8KcgIq4XOOTYRw48yUGON/Pr64iDrYNZKcKvKbqE0MPW56FF2BXA=="],
"express": ["express@4.21.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "0.19.0", "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA=="],
···
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
+
+
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
···
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
+
"hono": ["hono@4.10.6", "", {}, "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g=="],
+
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
···
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
+
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"iron-session": ["iron-session@8.0.4", "", { "dependencies": { "cookie": "^0.7.2", "iron-webcrypto": "^1.2.1", "uncrypto": "^0.1.3" } }, "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA=="],
···
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
+
+
"p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="],
+
+
"p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ=="],
+
+
"p-timeout": ["p-timeout@3.2.0", "", { "dependencies": { "p-finally": "^1.0.0" } }, "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
···
"playwright-core": ["playwright-core@1.56.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ=="],
+
"postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
+
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
···
"require-in-the-middle": ["require-in-the-middle@7.5.2", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3", "resolve": "^1.22.8" } }, "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
+
+
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
···
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
"tsx": ["tsx@4.20.6", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg=="],
+
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
···
"undici": ["undici@6.22.0", "", {}, "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw=="],
+
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+
+
"unicode-segmenter": ["unicode-segmenter@0.14.0", "", {}, "sha512-AH4lhPCJANUnSLEKnM4byboctePJzltF4xj8b+NbNiYeAkAXGh7px2K/4NANFp7dnr6+zB3e6HLu8Jj8SKyvYg=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
···
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
+
"varint": ["varint@6.0.0", "", {}, "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg=="],
+
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
+
+
"wisp-hosting-service": ["wisp-hosting-service@workspace:apps/hosting-service"],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
···
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
+
"@atproto-labs/fetch-node/ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="],
+
+
"@atproto/api/@atproto/lexicon": ["@atproto/lexicon@0.4.14", "", { "dependencies": { "@atproto/common-web": "^0.4.2", "@atproto/syntax": "^0.4.0", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-jiKpmH1QER3Gvc7JVY5brwrfo+etFoe57tKPQX/SmPwjvUsFnJAow5xLIryuBaJgFAhnTZViXKs41t//pahGHQ=="],
+
+
"@atproto/api/@atproto/xrpc": ["@atproto/xrpc@0.6.12", "", { "dependencies": { "@atproto/lexicon": "^0.4.10", "zod": "^3.23.8" } }, "sha512-Ut3iISNLujlmY9Gu8sNU+SPDJDvqlVzWddU8qUr0Yae5oD4SguaUFjjhireMGhQ3M5E0KljQgDbTmnBo1kIZ3w=="],
+
"@atproto/api/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
"@atproto/common/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
"@atproto/common-web/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
+
"@atproto/identity/@atproto/common-web": ["@atproto/common-web@0.4.5", "", { "dependencies": { "@atproto/lex-data": "0.0.1", "@atproto/lex-json": "0.0.1", "zod": "^3.23.8" } }, "sha512-Tx0xUafLm3vRvOQpbBl5eb9V8xlC7TaRXs6dAulHRkDG3Kb+P9qn3pkDteq+aeMshbVXbVa1rm3Ok4vFyuoyYA=="],
+
"@atproto/jwk/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
+
"@atproto/lex-cbor/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
+
+
"@atproto/lex-cli/@atproto/lexicon": ["@atproto/lexicon@0.5.1", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/syntax": "^0.4.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A=="],
+
+
"@atproto/lex-data/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
+
+
"@atproto/lexicon/@atproto/common-web": ["@atproto/common-web@0.4.5", "", { "dependencies": { "@atproto/lex-data": "0.0.1", "@atproto/lex-json": "0.0.1", "zod": "^3.23.8" } }, "sha512-Tx0xUafLm3vRvOQpbBl5eb9V8xlC7TaRXs6dAulHRkDG3Kb+P9qn3pkDteq+aeMshbVXbVa1rm3Ok4vFyuoyYA=="],
+
"@atproto/lexicon/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
"@atproto/oauth-client/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
+
"@atproto/repo/@atproto/common": ["@atproto/common@0.5.1", "", { "dependencies": { "@atproto/common-web": "^0.4.5", "@atproto/lex-cbor": "0.0.1", "@atproto/lex-data": "0.0.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "pino": "^8.21.0" } }, "sha512-0S57sjzw4r9OLc5srJFi6uAz/aTKYl6btz3x36tSnGriL716m6h0x2IVtgd+FhUfIQfisevrqcqw8SfaGk8VTw=="],
+
+
"@atproto/repo/@atproto/common-web": ["@atproto/common-web@0.4.5", "", { "dependencies": { "@atproto/lex-data": "0.0.1", "@atproto/lex-json": "0.0.1", "zod": "^3.23.8" } }, "sha512-Tx0xUafLm3vRvOQpbBl5eb9V8xlC7TaRXs6dAulHRkDG3Kb+P9qn3pkDteq+aeMshbVXbVa1rm3Ok4vFyuoyYA=="],
+
+
"@atproto/repo/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
+
+
"@atproto/sync/@atproto/common": ["@atproto/common@0.5.1", "", { "dependencies": { "@atproto/common-web": "^0.4.5", "@atproto/lex-cbor": "0.0.1", "@atproto/lex-data": "0.0.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "pino": "^8.21.0" } }, "sha512-0S57sjzw4r9OLc5srJFi6uAz/aTKYl6btz3x36tSnGriL716m6h0x2IVtgd+FhUfIQfisevrqcqw8SfaGk8VTw=="],
+
+
"@atproto/sync/@atproto/xrpc-server": ["@atproto/xrpc-server@0.10.1", "", { "dependencies": { "@atproto/common": "^0.5.1", "@atproto/crypto": "^0.4.4", "@atproto/lex-cbor": "0.0.1", "@atproto/lex-data": "0.0.1", "@atproto/lexicon": "^0.5.2", "@atproto/ws-client": "^0.0.3", "@atproto/xrpc": "^0.7.6", "express": "^4.17.2", "http-errors": "^2.0.0", "mime-types": "^2.1.35", "rate-limiter-flexible": "^2.4.1", "ws": "^8.12.0", "zod": "^3.23.8" } }, "sha512-kHXykL4inBV/49vefn5zR5zv/VM1//+BIRqk9OvB3+mbERw0jkFiHhc6PWyY/81VD4ciu7FZwUCpRy/mtQtIaA=="],
+
+
"@atproto/sync/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
+
+
"@atproto/ws-client/@atproto/common": ["@atproto/common@0.5.1", "", { "dependencies": { "@atproto/common-web": "^0.4.5", "@atproto/lex-cbor": "0.0.1", "@atproto/lex-data": "0.0.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "pino": "^8.21.0" } }, "sha512-0S57sjzw4r9OLc5srJFi6uAz/aTKYl6btz3x36tSnGriL716m6h0x2IVtgd+FhUfIQfisevrqcqw8SfaGk8VTw=="],
+
+
"@atproto/xrpc/@atproto/lexicon": ["@atproto/lexicon@0.5.1", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/syntax": "^0.4.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A=="],
+
+
"@atproto/xrpc-server/@atproto/lexicon": ["@atproto/lexicon@0.5.1", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/syntax": "^0.4.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A=="],
+
"@ipld/dag-cbor/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
···
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@tokenizer/inflate/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+
+
"@wisp/main-app/@atproto/api": ["@atproto/api@0.17.7", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "@atproto/xrpc": "^0.7.5", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-V+OJBZq9chcrD21xk1bUa6oc5DSKfQj5DmUPf5rmZncqL1w9ZEbS38H5cMyqqdhfgo2LWeDRdZHD0rvNyJsIaw=="],
+
+
"bun-types/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
"express/cookie": ["cookie@0.7.1", "", {}, "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="],
"iron-session/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
+
"protobufjs/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
"require-in-the-middle/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
···
"send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
"tsx/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
+
+
"tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
"uint8arrays/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
+
"wisp-hosting-service/@atproto/api": ["@atproto/api@0.17.7", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "@atproto/xrpc": "^0.7.5", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-V+OJBZq9chcrD21xk1bUa6oc5DSKfQj5DmUPf5rmZncqL1w9ZEbS38H5cMyqqdhfgo2LWeDRdZHD0rvNyJsIaw=="],
+
+
"wisp-hosting-service/@atproto/lexicon": ["@atproto/lexicon@0.5.1", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/syntax": "^0.4.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A=="],
+
+
"@atproto/lex-cli/@atproto/lexicon/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
+
+
"@atproto/sync/@atproto/common/@atproto/common-web": ["@atproto/common-web@0.4.5", "", { "dependencies": { "@atproto/lex-data": "0.0.1", "@atproto/lex-json": "0.0.1", "zod": "^3.23.8" } }, "sha512-Tx0xUafLm3vRvOQpbBl5eb9V8xlC7TaRXs6dAulHRkDG3Kb+P9qn3pkDteq+aeMshbVXbVa1rm3Ok4vFyuoyYA=="],
+
+
"@atproto/sync/@atproto/xrpc-server/@atproto/xrpc": ["@atproto/xrpc@0.7.6", "", { "dependencies": { "@atproto/lexicon": "^0.5.2", "zod": "^3.23.8" } }, "sha512-RvCf4j0JnKYWuz3QzsYCntJi3VuiAAybQsMIUw2wLWcHhchO9F7UaBZINLL2z0qc/cYWPv5NSwcVydMseoCZLA=="],
+
+
"@atproto/ws-client/@atproto/common/@atproto/common-web": ["@atproto/common-web@0.4.5", "", { "dependencies": { "@atproto/lex-data": "0.0.1", "@atproto/lex-json": "0.0.1", "zod": "^3.23.8" } }, "sha512-Tx0xUafLm3vRvOQpbBl5eb9V8xlC7TaRXs6dAulHRkDG3Kb+P9qn3pkDteq+aeMshbVXbVa1rm3Ok4vFyuoyYA=="],
+
+
"@atproto/ws-client/@atproto/common/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
+
+
"@atproto/xrpc-server/@atproto/lexicon/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
+
+
"@atproto/xrpc/@atproto/lexicon/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
+
"@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+
"@wisp/main-app/@atproto/api/@atproto/lexicon": ["@atproto/lexicon@0.5.1", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/syntax": "^0.4.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A=="],
+
+
"@wisp/main-app/@atproto/api/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
+
+
"bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+
+
"protobufjs/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"require-in-the-middle/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
+
+
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
+
+
"tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
+
+
"tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
+
+
"tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
+
+
"tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
+
+
"tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
+
+
"tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
+
+
"tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
+
+
"tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
+
+
"tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
+
+
"tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
+
+
"tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
+
+
"tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
+
+
"tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
+
+
"tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
+
+
"tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
+
+
"tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
+
+
"tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
+
+
"tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
+
+
"tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
+
+
"tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
+
+
"tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
+
+
"tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
+
+
"tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
+
+
"tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
+
+
"wisp-hosting-service/@atproto/api/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
+
+
"wisp-hosting-service/@atproto/lexicon/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
}
}
hosting-service/.dockerignore apps/hosting-service/.dockerignore
hosting-service/.env.example apps/hosting-service/.env.example
hosting-service/.gitignore apps/hosting-service/.gitignore
hosting-service/Dockerfile apps/hosting-service/Dockerfile
hosting-service/README.md apps/hosting-service/README.md
hosting-service/bun.lock apps/hosting-service/bun.lock
-51
hosting-service/debug-settings.ts
···
-
#!/usr/bin/env tsx
-
/**
-
* Debug script to check cached settings for a site
-
* Usage: tsx debug-settings.ts <did> <rkey>
-
*/
-
-
import { readFile } from 'fs/promises';
-
import { existsSync } from 'fs';
-
-
const CACHE_DIR = './cache';
-
-
async function debugSettings(did: string, rkey: string) {
-
const metadataPath = `${CACHE_DIR}/${did}/${rkey}/.metadata.json`;
-
-
console.log('Checking metadata at:', metadataPath);
-
console.log('Exists:', existsSync(metadataPath));
-
-
if (!existsSync(metadataPath)) {
-
console.log('\n❌ Metadata file does not exist - site may not be cached yet');
-
return;
-
}
-
-
const content = await readFile(metadataPath, 'utf-8');
-
const metadata = JSON.parse(content);
-
-
console.log('\n=== Cached Metadata ===');
-
console.log('CID:', metadata.cid);
-
console.log('Cached at:', metadata.cachedAt);
-
console.log('\n=== Settings ===');
-
if (metadata.settings) {
-
console.log(JSON.stringify(metadata.settings, null, 2));
-
} else {
-
console.log('❌ No settings found in metadata');
-
console.log('This means:');
-
console.log(' 1. No place.wisp.settings record exists on the PDS');
-
console.log(' 2. Or the firehose hasn\'t picked up the settings yet');
-
console.log('\nTo fix:');
-
console.log(' 1. Create a place.wisp.settings record with the same rkey');
-
console.log(' 2. Wait for firehose to pick it up (a few seconds)');
-
console.log(' 3. Or manually re-cache the site');
-
}
-
}
-
-
const [did, rkey] = process.argv.slice(2);
-
if (!did || !rkey) {
-
console.log('Usage: tsx debug-settings.ts <did> <rkey>');
-
console.log('Example: tsx debug-settings.ts did:plc:abc123 my-site');
-
process.exit(1);
-
}
-
-
debugSettings(did, rkey).catch(console.error);
···
hosting-service/docker-entrypoint.sh apps/hosting-service/docker-entrypoint.sh
-134
hosting-service/example-_redirects
···
-
# Example _redirects file for Wisp hosting
-
# Place this file in the root directory of your site as "_redirects"
-
# Lines starting with # are comments
-
-
# ===================================
-
# SIMPLE REDIRECTS
-
# ===================================
-
-
# Redirect home page
-
# /home /
-
-
# Redirect old URLs to new ones
-
# /old-blog /blog
-
# /about-us /about
-
-
# ===================================
-
# SPLAT REDIRECTS (WILDCARDS)
-
# ===================================
-
-
# Redirect entire directories
-
# /news/* /blog/:splat
-
# /old-site/* /new-site/:splat
-
-
# ===================================
-
# PLACEHOLDER REDIRECTS
-
# ===================================
-
-
# Restructure blog URLs
-
# /blog/:year/:month/:day/:slug /posts/:year-:month-:day/:slug
-
-
# Capture multiple parameters
-
# /products/:category/:id /shop/:category/item/:id
-
-
# ===================================
-
# STATUS CODES
-
# ===================================
-
-
# Permanent redirect (301) - default if not specified
-
# /permanent-move /new-location 301
-
-
# Temporary redirect (302)
-
# /temp-redirect /temp-location 302
-
-
# Rewrite (200) - serves different content, URL stays the same
-
# /api/* /functions/:splat 200
-
-
# Custom 404 page
-
# /shop/* /shop-closed.html 404
-
-
# ===================================
-
# FORCE REDIRECTS
-
# ===================================
-
-
# Force redirect even if file exists (note the ! after status code)
-
# /override-file /other-file.html 200!
-
-
# ===================================
-
# CONDITIONAL REDIRECTS
-
# ===================================
-
-
# Country-based redirects (ISO 3166-1 alpha-2 codes)
-
# / /us/ 302 Country=us
-
# / /uk/ 302 Country=gb
-
# / /anz/ 302 Country=au,nz
-
-
# Language-based redirects
-
# /products /en/products 301 Language=en
-
# /products /de/products 301 Language=de
-
# /products /fr/products 301 Language=fr
-
-
# Cookie-based redirects (checks if cookie exists)
-
# /* /legacy/:splat 200 Cookie=is_legacy
-
-
# ===================================
-
# QUERY PARAMETERS
-
# ===================================
-
-
# Match specific query parameters
-
# /store id=:id /blog/:id 301
-
-
# Multiple parameters
-
# /search q=:query category=:cat /find/:cat/:query 301
-
-
# ===================================
-
# DOMAIN-LEVEL REDIRECTS
-
# ===================================
-
-
# Redirect to different domain (must include protocol)
-
# /external https://example.com/path
-
-
# Redirect entire subdomain
-
# http://blog.example.com/* https://example.com/blog/:splat 301!
-
# https://blog.example.com/* https://example.com/blog/:splat 301!
-
-
# ===================================
-
# COMMON PATTERNS
-
# ===================================
-
-
# Remove .html extensions
-
# /page.html /page
-
-
# Add trailing slash
-
# /about /about/
-
-
# Single-page app fallback (serve index.html for all paths)
-
# /* /index.html 200
-
-
# API proxy
-
# /api/* https://api.example.com/:splat 200
-
-
# ===================================
-
# CUSTOM ERROR PAGES
-
# ===================================
-
-
# Language-specific 404 pages
-
# /en/* /en/404.html 404
-
# /de/* /de/404.html 404
-
-
# Section-specific 404 pages
-
# /shop/* /shop/not-found.html 404
-
# /blog/* /blog/404.html 404
-
-
# ===================================
-
# NOTES
-
# ===================================
-
#
-
# - Rules are processed in order (first match wins)
-
# - More specific rules should come before general ones
-
# - Splats (*) can only be used at the end of a path
-
# - Query parameters are automatically preserved for 200, 301, 302
-
# - Trailing slashes are normalized (/ and no / are treated the same)
-
# - Default status code is 301 if not specified
-
#
-
···
+7
hosting-service/package.json apps/hosting-service/package.json
···
"backfill": "tsx src/index.ts --backfill"
},
"dependencies": {
"@atproto/api": "^0.17.4",
"@atproto/identity": "^0.4.9",
"@atproto/lexicon": "^0.5.1",
···
"backfill": "tsx src/index.ts --backfill"
},
"dependencies": {
+
"@wisp/lexicons": "workspace:*",
+
"@wisp/constants": "workspace:*",
+
"@wisp/observability": "workspace:*",
+
"@wisp/atproto-utils": "workspace:*",
+
"@wisp/database": "workspace:*",
+
"@wisp/fs-utils": "workspace:*",
+
"@wisp/safe-fetch": "workspace:*",
"@atproto/api": "^0.17.4",
"@atproto/identity": "^0.4.9",
"@atproto/lexicon": "^0.5.1",
+3 -1
hosting-service/src/index.ts apps/hosting-service/src/index.ts
···
import app from './server';
import { serve } from '@hono/node-server';
import { FirehoseWorker } from './lib/firehose';
-
import { logger } from './lib/observability';
import { mkdirSync, existsSync } from 'fs';
import { backfillCache } from './lib/backfill';
import { startDomainCacheCleanup, stopDomainCacheCleanup, setCacheOnlyMode } from './lib/db';
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3001;
const CACHE_DIR = process.env.CACHE_DIR || './cache/sites';
···
import app from './server';
import { serve } from '@hono/node-server';
import { FirehoseWorker } from './lib/firehose';
+
import { createLogger } from '@wisp/observability';
import { mkdirSync, existsSync } from 'fs';
import { backfillCache } from './lib/backfill';
import { startDomainCacheCleanup, stopDomainCacheCleanup, setCacheOnlyMode } from './lib/db';
+
+
const logger = createLogger('hosting-service');
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3001;
const CACHE_DIR = process.env.CACHE_DIR || './cache/sites';
-44
hosting-service/src/lexicon/index.ts
···
-
/**
-
* GENERATED CODE - DO NOT MODIFY
-
*/
-
import {
-
type Auth,
-
type Options as XrpcOptions,
-
Server as XrpcServer,
-
type StreamConfigOrHandler,
-
type MethodConfigOrHandler,
-
createServer as createXrpcServer,
-
} from '@atproto/xrpc-server'
-
import { schemas } from './lexicons.js'
-
-
export function createServer(options?: XrpcOptions): Server {
-
return new Server(options)
-
}
-
-
export class Server {
-
xrpc: XrpcServer
-
place: PlaceNS
-
-
constructor(options?: XrpcOptions) {
-
this.xrpc = createXrpcServer(schemas, options)
-
this.place = new PlaceNS(this)
-
}
-
}
-
-
export class PlaceNS {
-
_server: Server
-
wisp: PlaceWispNS
-
-
constructor(server: Server) {
-
this._server = server
-
this.wisp = new PlaceWispNS(server)
-
}
-
}
-
-
export class PlaceWispNS {
-
_server: Server
-
-
constructor(server: Server) {
-
this._server = server
-
}
-
}
···
-364
hosting-service/src/lexicon/lexicons.ts
···
-
/**
-
* GENERATED CODE - DO NOT MODIFY
-
*/
-
import {
-
type LexiconDoc,
-
Lexicons,
-
ValidationError,
-
type ValidationResult,
-
} from '@atproto/lexicon'
-
import { type $Typed, is$typed, maybe$typed } from './util.js'
-
-
export const schemaDict = {
-
PlaceWispFs: {
-
lexicon: 1,
-
id: 'place.wisp.fs',
-
defs: {
-
main: {
-
type: 'record',
-
description: 'Virtual filesystem manifest for a Wisp site',
-
record: {
-
type: 'object',
-
required: ['site', 'root', 'createdAt'],
-
properties: {
-
site: {
-
type: 'string',
-
},
-
root: {
-
type: 'ref',
-
ref: 'lex:place.wisp.fs#directory',
-
},
-
fileCount: {
-
type: 'integer',
-
minimum: 0,
-
maximum: 1000,
-
},
-
createdAt: {
-
type: 'string',
-
format: 'datetime',
-
},
-
},
-
},
-
},
-
file: {
-
type: 'object',
-
required: ['type', 'blob'],
-
properties: {
-
type: {
-
type: 'string',
-
const: 'file',
-
},
-
blob: {
-
type: 'blob',
-
accept: ['*/*'],
-
maxSize: 1000000000,
-
description: 'Content blob ref',
-
},
-
encoding: {
-
type: 'string',
-
enum: ['gzip'],
-
description: 'Content encoding (e.g., gzip for compressed files)',
-
},
-
mimeType: {
-
type: 'string',
-
description: 'Original MIME type before compression',
-
},
-
base64: {
-
type: 'boolean',
-
description:
-
'True if blob content is base64-encoded (used to bypass PDS content sniffing)',
-
},
-
},
-
},
-
directory: {
-
type: 'object',
-
required: ['type', 'entries'],
-
properties: {
-
type: {
-
type: 'string',
-
const: 'directory',
-
},
-
entries: {
-
type: 'array',
-
maxLength: 500,
-
items: {
-
type: 'ref',
-
ref: 'lex:place.wisp.fs#entry',
-
},
-
},
-
},
-
},
-
entry: {
-
type: 'object',
-
required: ['name', 'node'],
-
properties: {
-
name: {
-
type: 'string',
-
maxLength: 255,
-
},
-
node: {
-
type: 'union',
-
refs: [
-
'lex:place.wisp.fs#file',
-
'lex:place.wisp.fs#directory',
-
'lex:place.wisp.fs#subfs',
-
],
-
},
-
},
-
},
-
subfs: {
-
type: 'object',
-
required: ['type', 'subject'],
-
properties: {
-
type: {
-
type: 'string',
-
const: 'subfs',
-
},
-
subject: {
-
type: 'string',
-
format: 'at-uri',
-
description:
-
'AT-URI pointing to a place.wisp.subfs record containing this subtree.',
-
},
-
flat: {
-
type: 'boolean',
-
description:
-
"If true (default), the subfs record's root entries are merged (flattened) into the parent directory, replacing the subfs entry. If false, the subfs entries are placed in a subdirectory with the subfs entry's name. Flat merging is useful for splitting large directories across multiple records while maintaining a flat structure.",
-
},
-
},
-
},
-
},
-
},
-
PlaceWispSettings: {
-
lexicon: 1,
-
id: 'place.wisp.settings',
-
defs: {
-
main: {
-
type: 'record',
-
description:
-
'Configuration settings for a static site hosted on wisp.place',
-
key: 'any',
-
record: {
-
type: 'object',
-
properties: {
-
directoryListing: {
-
type: 'boolean',
-
description:
-
'Enable directory listing mode for paths that resolve to directories without an index file. Incompatible with spaMode.',
-
default: false,
-
},
-
spaMode: {
-
type: 'string',
-
description:
-
"File to serve for all routes (e.g., 'index.html'). When set, enables SPA mode where all non-file requests are routed to this file. Incompatible with directoryListing and custom404.",
-
maxLength: 500,
-
},
-
custom404: {
-
type: 'string',
-
description:
-
'Custom 404 error page file path. Incompatible with directoryListing and spaMode.',
-
maxLength: 500,
-
},
-
indexFiles: {
-
type: 'array',
-
description:
-
"Ordered list of files to try when serving a directory. Defaults to ['index.html'] if not specified.",
-
items: {
-
type: 'string',
-
maxLength: 255,
-
},
-
maxLength: 10,
-
},
-
cleanUrls: {
-
type: 'boolean',
-
description:
-
"Enable clean URL routing. When enabled, '/about' will attempt to serve '/about.html' or '/about/index.html' automatically.",
-
default: false,
-
},
-
headers: {
-
type: 'array',
-
description: 'Custom HTTP headers to set on responses',
-
items: {
-
type: 'ref',
-
ref: 'lex:place.wisp.settings#customHeader',
-
},
-
maxLength: 50,
-
},
-
},
-
},
-
},
-
customHeader: {
-
type: 'object',
-
description: 'Custom HTTP header configuration',
-
required: ['name', 'value'],
-
properties: {
-
name: {
-
type: 'string',
-
description:
-
"HTTP header name (e.g., 'Cache-Control', 'X-Frame-Options')",
-
maxLength: 100,
-
},
-
value: {
-
type: 'string',
-
description: 'HTTP header value',
-
maxLength: 1000,
-
},
-
path: {
-
type: 'string',
-
description:
-
"Optional glob pattern to apply this header to specific paths (e.g., '*.html', '/assets/*'). If not specified, applies to all paths.",
-
maxLength: 500,
-
},
-
},
-
},
-
},
-
},
-
PlaceWispSubfs: {
-
lexicon: 1,
-
id: 'place.wisp.subfs',
-
defs: {
-
main: {
-
type: 'record',
-
description:
-
'Virtual filesystem subtree referenced by place.wisp.fs records. When a subfs entry is expanded, its root entries are merged (flattened) into the parent directory, allowing large directories to be split across multiple records while maintaining a flat structure.',
-
record: {
-
type: 'object',
-
required: ['root', 'createdAt'],
-
properties: {
-
root: {
-
type: 'ref',
-
ref: 'lex:place.wisp.subfs#directory',
-
},
-
fileCount: {
-
type: 'integer',
-
minimum: 0,
-
maximum: 1000,
-
},
-
createdAt: {
-
type: 'string',
-
format: 'datetime',
-
},
-
},
-
},
-
},
-
file: {
-
type: 'object',
-
required: ['type', 'blob'],
-
properties: {
-
type: {
-
type: 'string',
-
const: 'file',
-
},
-
blob: {
-
type: 'blob',
-
accept: ['*/*'],
-
maxSize: 1000000000,
-
description: 'Content blob ref',
-
},
-
encoding: {
-
type: 'string',
-
enum: ['gzip'],
-
description: 'Content encoding (e.g., gzip for compressed files)',
-
},
-
mimeType: {
-
type: 'string',
-
description: 'Original MIME type before compression',
-
},
-
base64: {
-
type: 'boolean',
-
description:
-
'True if blob content is base64-encoded (used to bypass PDS content sniffing)',
-
},
-
},
-
},
-
directory: {
-
type: 'object',
-
required: ['type', 'entries'],
-
properties: {
-
type: {
-
type: 'string',
-
const: 'directory',
-
},
-
entries: {
-
type: 'array',
-
maxLength: 500,
-
items: {
-
type: 'ref',
-
ref: 'lex:place.wisp.subfs#entry',
-
},
-
},
-
},
-
},
-
entry: {
-
type: 'object',
-
required: ['name', 'node'],
-
properties: {
-
name: {
-
type: 'string',
-
maxLength: 255,
-
},
-
node: {
-
type: 'union',
-
refs: [
-
'lex:place.wisp.subfs#file',
-
'lex:place.wisp.subfs#directory',
-
'lex:place.wisp.subfs#subfs',
-
],
-
},
-
},
-
},
-
subfs: {
-
type: 'object',
-
required: ['type', 'subject'],
-
properties: {
-
type: {
-
type: 'string',
-
const: 'subfs',
-
},
-
subject: {
-
type: 'string',
-
format: 'at-uri',
-
description:
-
"AT-URI pointing to another place.wisp.subfs record for nested subtrees. When expanded, the referenced record's root entries are merged (flattened) into the parent directory, allowing recursive splitting of large directory structures.",
-
},
-
},
-
},
-
},
-
},
-
} as const satisfies Record<string, LexiconDoc>
-
export const schemas = Object.values(schemaDict) satisfies LexiconDoc[]
-
export const lexicons: Lexicons = new Lexicons(schemas)
-
-
export function validate<T extends { $type: string }>(
-
v: unknown,
-
id: string,
-
hash: string,
-
requiredType: true,
-
): ValidationResult<T>
-
export function validate<T extends { $type?: string }>(
-
v: unknown,
-
id: string,
-
hash: string,
-
requiredType?: false,
-
): ValidationResult<T>
-
export function validate(
-
v: unknown,
-
id: string,
-
hash: string,
-
requiredType?: boolean,
-
): ValidationResult {
-
return (requiredType ? is$typed : maybe$typed)(v, id, hash)
-
? lexicons.validate(`${id}#${hash}`, v)
-
: {
-
success: false,
-
error: new ValidationError(
-
`Must be an object with "${hash === 'main' ? id : `${id}#${hash}`}" $type property`,
-
),
-
}
-
}
-
-
export const ids = {
-
PlaceWispFs: 'place.wisp.fs',
-
PlaceWispSettings: 'place.wisp.settings',
-
PlaceWispSubfs: 'place.wisp.subfs',
-
} as const
···
hosting-service/src/lexicon/types/place/wisp/fs.ts packages/@wisp/lexicons/src/types/place/wisp/fs.ts
hosting-service/src/lexicon/types/place/wisp/settings.ts packages/@wisp/lexicons/src/types/place/wisp/settings.ts
hosting-service/src/lexicon/types/place/wisp/subfs.ts packages/@wisp/lexicons/src/types/place/wisp/subfs.ts
hosting-service/src/lexicon/util.ts packages/@wisp/lexicons/src/util.ts
+4 -2
hosting-service/src/lib/backfill.ts apps/hosting-service/src/lib/backfill.ts
···
import { getAllSites } from './db';
import { fetchSiteRecord, getPdsForDid, downloadAndCacheSite, isCached } from './utils';
-
import { logger } from './observability';
import { markSiteAsBeingCached, unmarkSiteAsBeingCached } from './cache';
-
import { clearRedirectRulesCache } from '../server';
export interface BackfillOptions {
skipExisting?: boolean; // Skip sites already in cache
···
import { getAllSites } from './db';
import { fetchSiteRecord, getPdsForDid, downloadAndCacheSite, isCached } from './utils';
+
import { createLogger } from '@wisp/observability';
import { markSiteAsBeingCached, unmarkSiteAsBeingCached } from './cache';
+
import { clearRedirectRulesCache } from './site-cache';
+
+
const logger = createLogger('hosting-service');
export interface BackfillOptions {
skipExisting?: boolean; // Skip sites already in cache
hosting-service/src/lib/cache.ts apps/hosting-service/src/lib/cache.ts
+1 -15
hosting-service/src/lib/db.ts apps/hosting-service/src/lib/db.ts
···
import postgres from 'postgres';
import { createHash } from 'crypto';
// Global cache-only mode flag (set by index.ts)
let cacheOnlyMode = false;
···
cleanupInterval = null;
}
}
-
-
export interface DomainLookup {
-
did: string;
-
rkey: string | null;
-
}
-
-
export interface CustomDomainLookup {
-
id: string;
-
domain: string;
-
did: string;
-
rkey: string | null;
-
verified: boolean;
-
}
-
-
export async function getWispDomain(domain: string): Promise<DomainLookup | null> {
const key = domain.toLowerCase();
···
import postgres from 'postgres';
import { createHash } from 'crypto';
+
import type { DomainLookup, CustomDomainLookup } from '@wisp/database';
// Global cache-only mode flag (set by index.ts)
let cacheOnlyMode = false;
···
cleanupInterval = null;
}
}
export async function getWispDomain(domain: string): Promise<DomainLookup | null> {
const key = domain.toLowerCase();
+3 -4
hosting-service/src/lib/firehose.ts apps/hosting-service/src/lib/firehose.ts
···
import {
getPdsForDid,
downloadAndCacheSite,
-
extractBlobCid,
fetchSiteRecord
} from './utils'
import { upsertSite, tryAcquireLock, releaseLock } from './db'
-
import { safeFetch } from './safe-fetch'
-
import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs'
import { Firehose } from '@atproto/sync'
import { IdResolver } from '@atproto/identity'
import { invalidateSiteCache, markSiteAsBeingCached, unmarkSiteAsBeingCached } from './cache'
-
import { clearRedirectRulesCache } from '../server'
const CACHE_DIR = './cache/sites'
···
import {
getPdsForDid,
downloadAndCacheSite,
fetchSiteRecord
} from './utils'
import { upsertSite, tryAcquireLock, releaseLock } from './db'
+
import { safeFetch } from '@wisp/safe-fetch'
+
import { isRecord, validateRecord } from '@wisp/lexicons/types/place/wisp/fs'
import { Firehose } from '@atproto/sync'
import { IdResolver } from '@atproto/identity'
import { invalidateSiteCache, markSiteAsBeingCached, unmarkSiteAsBeingCached } from './cache'
+
import { clearRedirectRulesCache } from './site-cache'
const CACHE_DIR = './cache/sites'
hosting-service/src/lib/html-rewriter.test.ts apps/hosting-service/src/lib/html-rewriter.test.ts
hosting-service/src/lib/html-rewriter.ts apps/hosting-service/src/lib/html-rewriter.ts
+103 -61
hosting-service/src/lib/observability.ts packages/@wisp/observability/src/core.ts
···
-
// DIY Observability for Hosting Service
-
import type { Context } from 'hono'
// Types
export interface LogEntry {
id: string
timestamp: Date
···
service: string
}
-
// In-memory storage with rotation
const MAX_LOGS = 5000
const MAX_ERRORS = 500
const MAX_METRICS = 10000
const logs: LogEntry[] = []
const errors: Map<string, ErrorEntry> = new Map()
const metrics: MetricEntry[] = []
-
// Helper to generate unique IDs
let logCounter = 0
let errorCounter = 0
···
return `${prefix}-${Date.now()}-${counter}`
}
-
// Helper to extract event type from message
function extractEventType(message: string): string | undefined {
const match = message.match(/^\[([^\]]+)\]/)
return match ? match[1] : undefined
}
-
// Log collector
export const logCollector = {
-
log(level: LogEntry['level'], message: string, service: string, context?: Record<string, any>, traceId?: string) {
const entry: LogEntry = {
id: generateId('log', logCounter++),
timestamp: new Date(),
···
this.log('warn', message, service, context, traceId)
},
-
error(message: string, service: string, error?: any, context?: Record<string, any>, traceId?: string) {
const ctx = { ...context }
if (error instanceof Error) {
ctx.error = error.message
···
},
debug(message: string, service: string, context?: Record<string, any>, traceId?: string) {
-
if (process.env.NODE_ENV !== 'production') {
this.log('debug', message, service, context, traceId)
}
},
-
getLogs(filter?: { level?: string; service?: string; limit?: number; search?: string; eventType?: string }) {
let filtered = [...logs]
if (filter?.level) {
···
const search = filter.search.toLowerCase()
filtered = filtered.filter(log =>
log.message.toLowerCase().includes(search) ||
-
JSON.stringify(log.context).toLowerCase().includes(search)
)
}
···
}
}
-
// Error tracker with deduplication
export const errorTracker = {
track(message: string, service: string, error?: any, context?: Record<string, any>) {
const key = `${service}:${message}`
···
}
},
-
getErrors(filter?: { service?: string; limit?: number }) {
let filtered = Array.from(errors.values())
if (filter?.service) {
···
}
}
-
// Metrics collector
export const metricsCollector = {
-
recordRequest(path: string, method: string, statusCode: number, duration: number, service: string) {
const entry: MetricEntry = {
timestamp: new Date(),
path,
···
}
},
-
getMetrics(filter?: { service?: string; timeWindow?: number }) {
let filtered = [...metrics]
if (filter?.service) {
···
return filtered
},
-
getStats(service?: string, timeWindow: number = 3600000) {
const filtered = this.getMetrics({ service, timeWindow })
if (filtered.length === 0) {
···
}
}
-
// Hono middleware for request timing
-
export function observabilityMiddleware(service: string) {
-
return async (c: Context, next: () => Promise<void>) => {
-
const startTime = Date.now()
-
-
await next()
-
-
const duration = Date.now() - startTime
-
const { pathname } = new URL(c.req.url)
-
-
metricsCollector.recordRequest(
-
pathname,
-
c.req.method,
-
c.res.status,
-
duration,
-
service
-
)
-
}
-
}
-
// Hono error handler
-
export function observabilityErrorHandler(service: string) {
-
return (err: Error, c: Context) => {
-
const { pathname } = new URL(c.req.url)
-
-
logCollector.error(
-
`Request failed: ${c.req.method} ${pathname}`,
-
service,
-
err,
-
{ statusCode: c.res.status || 500 }
-
)
-
-
return c.text('Internal Server Error', 500)
}
}
-
-
// Export singleton logger for easy access
-
export const logger = {
-
info: (message: string, context?: Record<string, any>) =>
-
logCollector.info(message, 'hosting-service', context),
-
warn: (message: string, context?: Record<string, any>) =>
-
logCollector.warn(message, 'hosting-service', context),
-
error: (message: string, error?: any, context?: Record<string, any>) =>
-
logCollector.error(message, 'hosting-service', error, context),
-
debug: (message: string, context?: Record<string, any>) =>
-
logCollector.debug(message, 'hosting-service', context)
-
}
···
+
/**
+
* Core observability types and collectors
+
* Framework-agnostic logging, error tracking, and metrics collection
+
*/
+
// ============================================================================
// Types
+
// ============================================================================
+
export interface LogEntry {
id: string
timestamp: Date
···
service: string
}
+
export interface LogFilter {
+
level?: string
+
service?: string
+
limit?: number
+
search?: string
+
eventType?: string
+
}
+
+
export interface ErrorFilter {
+
service?: string
+
limit?: number
+
}
+
+
export interface MetricFilter {
+
service?: string
+
timeWindow?: number
+
}
+
+
export interface MetricStats {
+
totalRequests: number
+
avgDuration: number
+
p50Duration: number
+
p95Duration: number
+
p99Duration: number
+
errorRate: number
+
requestsPerMinute: number
+
}
+
+
// ============================================================================
+
// Configuration
+
// ============================================================================
+
const MAX_LOGS = 5000
const MAX_ERRORS = 500
const MAX_METRICS = 10000
+
// ============================================================================
+
// Storage
+
// ============================================================================
+
const logs: LogEntry[] = []
const errors: Map<string, ErrorEntry> = new Map()
const metrics: MetricEntry[] = []
+
// ============================================================================
+
// Helpers
+
// ============================================================================
+
let logCounter = 0
let errorCounter = 0
···
return `${prefix}-${Date.now()}-${counter}`
}
function extractEventType(message: string): string | undefined {
const match = message.match(/^\[([^\]]+)\]/)
return match ? match[1] : undefined
}
+
// ============================================================================
+
// Log Collector
+
// ============================================================================
+
export const logCollector = {
+
log(
+
level: LogEntry['level'],
+
message: string,
+
service: string,
+
context?: Record<string, any>,
+
traceId?: string
+
) {
const entry: LogEntry = {
id: generateId('log', logCounter++),
timestamp: new Date(),
···
this.log('warn', message, service, context, traceId)
},
+
error(
+
message: string,
+
service: string,
+
error?: any,
+
context?: Record<string, any>,
+
traceId?: string
+
) {
const ctx = { ...context }
if (error instanceof Error) {
ctx.error = error.message
···
},
debug(message: string, service: string, context?: Record<string, any>, traceId?: string) {
+
const env = typeof Bun !== 'undefined' ? Bun.env.NODE_ENV : process.env.NODE_ENV;
+
if (env !== 'production') {
this.log('debug', message, service, context, traceId)
}
},
+
getLogs(filter?: LogFilter) {
let filtered = [...logs]
if (filter?.level) {
···
const search = filter.search.toLowerCase()
filtered = filtered.filter(log =>
log.message.toLowerCase().includes(search) ||
+
(log.context ? JSON.stringify(log.context).toLowerCase().includes(search) : false)
)
}
···
}
}
+
// ============================================================================
+
// Error Tracker
+
// ============================================================================
+
export const errorTracker = {
track(message: string, service: string, error?: any, context?: Record<string, any>) {
const key = `${service}:${message}`
···
}
},
+
getErrors(filter?: ErrorFilter) {
let filtered = Array.from(errors.values())
if (filter?.service) {
···
}
}
+
// ============================================================================
+
// Metrics Collector
+
// ============================================================================
+
export const metricsCollector = {
+
recordRequest(
+
path: string,
+
method: string,
+
statusCode: number,
+
duration: number,
+
service: string
+
) {
const entry: MetricEntry = {
timestamp: new Date(),
path,
···
}
},
+
getMetrics(filter?: MetricFilter) {
let filtered = [...metrics]
if (filter?.service) {
···
return filtered
},
+
getStats(service?: string, timeWindow: number = 3600000): MetricStats {
const filtered = this.getMetrics({ service, timeWindow })
if (filtered.length === 0) {
···
}
}
+
// ============================================================================
+
// Logger Factory
+
// ============================================================================
+
/**
+
* Create a service-specific logger instance
+
*/
+
export function createLogger(service: string) {
+
return {
+
info: (message: string, context?: Record<string, any>) =>
+
logCollector.info(message, service, context),
+
warn: (message: string, context?: Record<string, any>) =>
+
logCollector.warn(message, service, context),
+
error: (message: string, error?: any, context?: Record<string, any>) =>
+
logCollector.error(message, service, error, context),
+
debug: (message: string, context?: Record<string, any>) =>
+
logCollector.debug(message, service, context)
}
}
hosting-service/src/lib/redirects.test.ts apps/hosting-service/src/lib/redirects.test.ts
hosting-service/src/lib/redirects.ts apps/hosting-service/src/lib/redirects.ts
-187
hosting-service/src/lib/safe-fetch.ts
···
-
/**
-
* SSRF-hardened fetch utility
-
* Prevents requests to private networks, localhost, and enforces timeouts/size limits
-
*/
-
-
const BLOCKED_IP_RANGES = [
-
/^127\./, // 127.0.0.0/8 - Loopback
-
/^10\./, // 10.0.0.0/8 - Private
-
/^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12 - Private
-
/^192\.168\./, // 192.168.0.0/16 - Private
-
/^169\.254\./, // 169.254.0.0/16 - Link-local
-
/^::1$/, // IPv6 loopback
-
/^fe80:/, // IPv6 link-local
-
/^fc00:/, // IPv6 unique local
-
/^fd00:/, // IPv6 unique local
-
];
-
-
const BLOCKED_HOSTS = [
-
'localhost',
-
'metadata.google.internal',
-
'169.254.169.254',
-
];
-
-
const FETCH_TIMEOUT = 120000; // 120 seconds
-
const FETCH_TIMEOUT_BLOB = 120000; // 2 minutes for blob downloads
-
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
-
const MAX_JSON_SIZE = 1024 * 1024; // 1MB
-
const MAX_BLOB_SIZE = 500 * 1024 * 1024; // 500MB
-
const MAX_REDIRECTS = 10;
-
-
function isBlockedHost(hostname: string): boolean {
-
const lowerHost = hostname.toLowerCase();
-
-
if (BLOCKED_HOSTS.includes(lowerHost)) {
-
return true;
-
}
-
-
for (const pattern of BLOCKED_IP_RANGES) {
-
if (pattern.test(lowerHost)) {
-
return true;
-
}
-
}
-
-
return false;
-
}
-
-
export async function safeFetch(
-
url: string,
-
options?: RequestInit & { maxSize?: number; timeout?: number }
-
): Promise<Response> {
-
const timeoutMs = options?.timeout ?? FETCH_TIMEOUT;
-
const maxSize = options?.maxSize ?? MAX_RESPONSE_SIZE;
-
-
// Parse and validate URL
-
let parsedUrl: URL;
-
try {
-
parsedUrl = new URL(url);
-
} catch (err) {
-
throw new Error(`Invalid URL: ${url}`);
-
}
-
-
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
-
throw new Error(`Blocked protocol: ${parsedUrl.protocol}`);
-
}
-
-
const hostname = parsedUrl.hostname;
-
if (isBlockedHost(hostname)) {
-
throw new Error(`Blocked host: ${hostname}`);
-
}
-
-
const controller = new AbortController();
-
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
-
-
try {
-
const response = await fetch(url, {
-
...options,
-
signal: controller.signal,
-
redirect: 'follow',
-
});
-
-
const contentLength = response.headers.get('content-length');
-
if (contentLength && parseInt(contentLength, 10) > maxSize) {
-
throw new Error(`Response too large: ${contentLength} bytes`);
-
}
-
-
return response;
-
} catch (err) {
-
if (err instanceof Error && err.name === 'AbortError') {
-
throw new Error(`Request timeout after ${timeoutMs}ms`);
-
}
-
throw err;
-
} finally {
-
clearTimeout(timeoutId);
-
}
-
}
-
-
export async function safeFetchJson<T = any>(
-
url: string,
-
options?: RequestInit & { maxSize?: number; timeout?: number }
-
): Promise<T> {
-
const maxJsonSize = options?.maxSize ?? MAX_JSON_SIZE;
-
const response = await safeFetch(url, { ...options, maxSize: maxJsonSize });
-
-
if (!response.ok) {
-
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
-
}
-
-
const reader = response.body?.getReader();
-
if (!reader) {
-
throw new Error('No response body');
-
}
-
-
const chunks: Uint8Array[] = [];
-
let totalSize = 0;
-
-
try {
-
while (true) {
-
const { done, value } = await reader.read();
-
if (done) break;
-
-
totalSize += value.length;
-
if (totalSize > maxJsonSize) {
-
throw new Error(`Response exceeds max size: ${maxJsonSize} bytes`);
-
}
-
-
chunks.push(value);
-
}
-
} finally {
-
reader.releaseLock();
-
}
-
-
const combined = new Uint8Array(totalSize);
-
let offset = 0;
-
for (const chunk of chunks) {
-
combined.set(chunk, offset);
-
offset += chunk.length;
-
}
-
-
const text = new TextDecoder().decode(combined);
-
return JSON.parse(text);
-
}
-
-
export async function safeFetchBlob(
-
url: string,
-
options?: RequestInit & { maxSize?: number; timeout?: number }
-
): Promise<Uint8Array> {
-
const maxBlobSize = options?.maxSize ?? MAX_BLOB_SIZE;
-
const timeoutMs = options?.timeout ?? FETCH_TIMEOUT_BLOB;
-
const response = await safeFetch(url, { ...options, maxSize: maxBlobSize, timeout: timeoutMs });
-
-
if (!response.ok) {
-
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
-
}
-
-
const reader = response.body?.getReader();
-
if (!reader) {
-
throw new Error('No response body');
-
}
-
-
const chunks: Uint8Array[] = [];
-
let totalSize = 0;
-
-
try {
-
while (true) {
-
const { done, value } = await reader.read();
-
if (done) break;
-
-
totalSize += value.length;
-
if (totalSize > maxBlobSize) {
-
throw new Error(`Blob exceeds max size: ${maxBlobSize} bytes`);
-
}
-
-
chunks.push(value);
-
}
-
} finally {
-
reader.releaseLock();
-
}
-
-
const combined = new Uint8Array(totalSize);
-
let offset = 0;
-
for (const chunk of chunks) {
-
combined.set(chunk, offset);
-
offset += chunk.length;
-
}
-
-
return combined;
-
}
···
hosting-service/src/lib/types.ts apps/hosting-service/src/lib/types.ts
hosting-service/src/lib/utils.test.ts apps/hosting-service/src/lib/utils.test.ts
+21 -154
hosting-service/src/lib/utils.ts apps/hosting-service/src/lib/utils.ts
···
import { AtpAgent } from '@atproto/api';
-
import type { Record as WispFsRecord, Directory, Entry, File } from '../lexicon/types/place/wisp/fs';
-
import type { Record as SubfsRecord } from '../lexicon/types/place/wisp/subfs';
-
import type { Record as WispSettings } from '../lexicon/types/place/wisp/settings';
import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs';
import { writeFile, readFile, rename } from 'fs/promises';
-
import { safeFetchJson, safeFetchBlob } from './safe-fetch';
import { CID } from 'multiformats';
const CACHE_DIR = process.env.CACHE_DIR || './cache/sites';
const CACHE_TTL = 14 * 24 * 60 * 60 * 1000; // 14 days cache TTL
···
settings?: WispSettings;
}
-
/**
-
* Determines if a MIME type should benefit from gzip compression.
-
* Returns true for text-based web assets (HTML, CSS, JS, JSON, XML, SVG).
-
* Returns false for already-compressed formats (images, video, audio, PDFs).
-
*
-
*/
-
export function shouldCompressMimeType(mimeType: string | undefined): boolean {
-
if (!mimeType) return false;
-
-
const mime = mimeType.toLowerCase();
-
-
// Text-based web assets and uncompressed audio that benefit from compression
-
const compressibleTypes = [
-
'text/html',
-
'text/css',
-
'text/javascript',
-
'application/javascript',
-
'application/x-javascript',
-
'text/xml',
-
'application/xml',
-
'application/json',
-
'text/plain',
-
'image/svg+xml',
-
// Uncompressed audio formats
-
'audio/wav',
-
'audio/wave',
-
'audio/x-wav',
-
'audio/aiff',
-
'audio/x-aiff',
-
];
-
-
if (compressibleTypes.some(type => mime === type || mime.startsWith(type))) {
-
return true;
-
}
-
-
// Already-compressed formats that should NOT be double-compressed
-
const alreadyCompressedPrefixes = [
-
'video/',
-
'audio/',
-
'image/',
-
'application/pdf',
-
'application/zip',
-
'application/gzip',
-
];
-
-
if (alreadyCompressedPrefixes.some(prefix => mime.startsWith(prefix))) {
-
return false;
-
}
-
-
// Default to not compressing for unknown types
-
return false;
-
}
-
-
interface IpldLink {
-
$link: string;
-
}
-
-
interface TypedBlobRef {
-
ref: CID | IpldLink;
-
}
-
-
interface UntypedBlobRef {
-
cid: string;
-
}
-
-
function isIpldLink(obj: unknown): obj is IpldLink {
-
return typeof obj === 'object' && obj !== null && '$link' in obj && typeof (obj as IpldLink).$link === 'string';
-
}
-
-
function isTypedBlobRef(obj: unknown): obj is TypedBlobRef {
-
return typeof obj === 'object' && obj !== null && 'ref' in obj;
-
}
-
-
function isUntypedBlobRef(obj: unknown): obj is UntypedBlobRef {
-
return typeof obj === 'object' && obj !== null && 'cid' in obj && typeof (obj as UntypedBlobRef).cid === 'string';
-
}
export async function resolveDid(identifier: string): Promise<string | null> {
try {
···
}
}
-
export function extractBlobCid(blobRef: unknown): string | null {
-
if (isIpldLink(blobRef)) {
-
return blobRef.$link;
-
}
-
-
if (isTypedBlobRef(blobRef)) {
-
const ref = blobRef.ref;
-
-
const cid = CID.asCID(ref);
-
if (cid) {
-
return cid.toString();
-
}
-
-
if (isIpldLink(ref)) {
-
return ref.$link;
-
}
-
}
-
-
if (isUntypedBlobRef(blobRef)) {
-
return blobRef.cid;
-
}
-
-
return null;
-
}
-
/**
* Extract all subfs URIs from a directory tree with their mount paths
*/
···
return null;
}
-
const did = parts[0];
-
const collection = parts[1];
-
const rkey = parts[2];
// Fetch the record from PDS
const url = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}`;
···
);
// Build a map of path -> root entries to merge
const subfsMap = new Map<string, Entry[]>();
for (const { record, path } of subfsRecords) {
if (record && record.root && record.root.entries) {
-
subfsMap.set(path, record.root.entries);
}
}
···
} else {
// Subdirectory merge: create a directory with the subfs node's name
const processedEntries = replaceSubfsInEntries(subfsEntries, fullPath);
result.push({
name: entry.name,
-
node: {
-
type: 'directory',
-
entries: processedEntries
-
}
});
}
} else {
···
entries: replaceSubfsInEntries(directory.entries)
};
}
export async function downloadAndCacheSite(did: string, rkey: string, record: WispFsRecord, pdsEndpoint: string, recordCid: string): Promise<void> {
console.log('Caching site', did, rkey);
···
}
}
-
/**
-
* Recursively collect file CIDs from entries for incremental update tracking
-
*/
-
function collectFileCidsFromEntries(entries: Entry[], pathPrefix: string, fileCids: Record<string, string>): void {
-
for (const entry of entries) {
-
const currentPath = pathPrefix ? `${pathPrefix}/${entry.name}` : entry.name;
-
const node = entry.node;
-
-
if ('type' in node && node.type === 'directory' && 'entries' in node) {
-
collectFileCidsFromEntries(node.entries, currentPath, fileCids);
-
} else if ('type' in node && node.type === 'file' && 'blob' in node) {
-
const fileNode = node as File;
-
const cid = extractBlobCid(fileNode.blob);
-
if (cid) {
-
fileCids[currentPath] = cid;
-
}
-
}
-
}
-
}
async function cacheFiles(
did: string,
···
}
}
-
/**
-
* Sanitize a file path to prevent directory traversal attacks
-
* Removes any path segments that attempt to go up directories
-
*/
-
export function sanitizePath(filePath: string): string {
-
// Remove leading slashes
-
let cleaned = filePath.replace(/^\/+/, '');
-
-
// Split into segments and filter out dangerous ones
-
const segments = cleaned.split('/').filter(segment => {
-
// Remove empty segments
-
if (!segment || segment === '.') return false;
-
// Remove parent directory references
-
if (segment === '..') return false;
-
// Remove segments with null bytes
-
if (segment.includes('\0')) return false;
-
return true;
-
});
-
-
// Rejoin the safe segments
-
return segments.join('/');
-
}
export function getCachedFilePath(did: string, site: string, filePath: string): string {
const sanitizedPath = sanitizePath(filePath);
···
import { AtpAgent } from '@atproto/api';
+
import type { Record as WispFsRecord, Directory, Entry, File } from '@wisp/lexicons/types/place/wisp/fs';
+
import type { Record as SubfsRecord } from '@wisp/lexicons/types/place/wisp/subfs';
+
import type { Record as WispSettings } from '@wisp/lexicons/types/place/wisp/settings';
import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs';
import { writeFile, readFile, rename } from 'fs/promises';
+
import { safeFetchJson, safeFetchBlob } from '@wisp/safe-fetch';
import { CID } from 'multiformats';
+
import { extractBlobCid } from '@wisp/atproto-utils';
+
import { sanitizePath, collectFileCidsFromEntries } from '@wisp/fs-utils';
+
import { shouldCompressMimeType } from '@wisp/atproto-utils/compression';
+
+
// Re-export shared utilities for local usage and tests
+
export { extractBlobCid, sanitizePath };
const CACHE_DIR = process.env.CACHE_DIR || './cache/sites';
const CACHE_TTL = 14 * 24 * 60 * 60 * 1000; // 14 days cache TTL
···
settings?: WispSettings;
}
export async function resolveDid(identifier: string): Promise<string | null> {
try {
···
}
}
/**
* Extract all subfs URIs from a directory tree with their mount paths
*/
···
return null;
}
+
const did = parts[0] || '';
+
const collection = parts[1] || '';
+
const rkey = parts[2] || '';
// Fetch the record from PDS
const url = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}`;
···
);
// Build a map of path -> root entries to merge
+
// Note: SubFS entries are compatible with FS entries at runtime
const subfsMap = new Map<string, Entry[]>();
for (const { record, path } of subfsRecords) {
if (record && record.root && record.root.entries) {
+
subfsMap.set(path, record.root.entries as unknown as Entry[]);
}
}
···
} else {
// Subdirectory merge: create a directory with the subfs node's name
const processedEntries = replaceSubfsInEntries(subfsEntries, fullPath);
+
const directoryNode: Directory = {
+
type: 'directory',
+
entries: processedEntries
+
};
result.push({
name: entry.name,
+
node: directoryNode as any // Type assertion needed due to lexicon type complexity
});
}
} else {
···
entries: replaceSubfsInEntries(directory.entries)
};
}
+
export async function downloadAndCacheSite(did: string, rkey: string, record: WispFsRecord, pdsEndpoint: string, recordCid: string): Promise<void> {
console.log('Caching site', did, rkey);
···
}
}
async function cacheFiles(
did: string,
···
}
}
export function getCachedFilePath(did: string, site: string, filePath: string): string {
const sanitizedPath = sanitizePath(filePath);
-1523
hosting-service/src/server.ts
···
-
import { Hono } from 'hono';
-
import { cors } from 'hono/cors';
-
import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db';
-
import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath, shouldCompressMimeType, getCachedSettings } from './lib/utils';
-
import type { Record as WispSettings } from './lexicon/types/place/wisp/settings';
-
import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter';
-
import { existsSync } from 'fs';
-
import { readFile, access } from 'fs/promises';
-
import { lookup } from 'mime-types';
-
import { logger, observabilityMiddleware, observabilityErrorHandler, logCollector, errorTracker, metricsCollector } from './lib/observability';
-
import { fileCache, metadataCache, rewrittenHtmlCache, getCacheKey, type FileMetadata, markSiteAsBeingCached, unmarkSiteAsBeingCached, isSiteBeingCached } from './lib/cache';
-
import { loadRedirectRules, matchRedirectRule, parseCookies, parseQueryString, type RedirectRule } from './lib/redirects';
-
-
const BASE_HOST = process.env.BASE_HOST || 'wisp.place';
-
-
/**
-
* Default index file names to check for directory requests
-
* Will be checked in order until one is found
-
*/
-
const DEFAULT_INDEX_FILES = ['index.html', 'index.htm'];
-
-
/**
-
* Get index files list from settings or use defaults
-
*/
-
function getIndexFiles(settings: WispSettings | null): string[] {
-
if (settings?.indexFiles && settings.indexFiles.length > 0) {
-
return settings.indexFiles;
-
}
-
return DEFAULT_INDEX_FILES;
-
}
-
-
/**
-
* Match a file path against a glob pattern
-
* Supports * wildcard and basic path matching
-
*/
-
function matchGlob(path: string, pattern: string): boolean {
-
// Normalize paths
-
const normalizedPath = path.startsWith('/') ? path : '/' + path;
-
const normalizedPattern = pattern.startsWith('/') ? pattern : '/' + pattern;
-
-
// Convert glob pattern to regex
-
const regexPattern = normalizedPattern
-
.replace(/\./g, '\\.')
-
.replace(/\*/g, '.*')
-
.replace(/\?/g, '.');
-
-
const regex = new RegExp('^' + regexPattern + '$');
-
return regex.test(normalizedPath);
-
}
-
-
/**
-
* Apply custom headers from settings to response headers
-
*/
-
function applyCustomHeaders(headers: Record<string, string>, filePath: string, settings: WispSettings | null) {
-
if (!settings?.headers || settings.headers.length === 0) return;
-
-
for (const customHeader of settings.headers) {
-
// If path glob is specified, check if it matches
-
if (customHeader.path) {
-
if (!matchGlob(filePath, customHeader.path)) {
-
continue;
-
}
-
}
-
// Apply the header
-
headers[customHeader.name] = customHeader.value;
-
}
-
}
-
-
/**
-
* Generate 404 page HTML
-
*/
-
function generate404Page(): string {
-
const html = `<!DOCTYPE html>
-
<html>
-
<head>
-
<meta charset="utf-8">
-
<meta name="viewport" content="width=device-width, initial-scale=1">
-
<title>404 - Not Found</title>
-
<style>
-
@media (prefers-color-scheme: light) {
-
:root {
-
/* Warm beige background */
-
--background: oklch(0.90 0.012 35);
-
/* Very dark brown text */
-
--foreground: oklch(0.18 0.01 30);
-
--border: oklch(0.75 0.015 30);
-
/* Bright pink accent for links */
-
--accent: oklch(0.78 0.15 345);
-
}
-
}
-
@media (prefers-color-scheme: dark) {
-
:root {
-
/* Slate violet background */
-
--background: oklch(0.23 0.015 285);
-
/* Light gray text */
-
--foreground: oklch(0.90 0.005 285);
-
/* Subtle borders */
-
--border: oklch(0.38 0.02 285);
-
/* Soft pink accent */
-
--accent: oklch(0.85 0.08 5);
-
}
-
}
-
body {
-
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
-
background: var(--background);
-
color: var(--foreground);
-
padding: 2rem;
-
max-width: 800px;
-
margin: 0 auto;
-
display: flex;
-
flex-direction: column;
-
min-height: 100vh;
-
justify-content: center;
-
align-items: center;
-
text-align: center;
-
}
-
h1 {
-
font-size: 6rem;
-
margin: 0;
-
font-weight: 700;
-
line-height: 1;
-
}
-
h2 {
-
font-size: 1.5rem;
-
margin: 1rem 0 2rem;
-
font-weight: 400;
-
opacity: 0.8;
-
}
-
p {
-
font-size: 1rem;
-
opacity: 0.7;
-
margin-bottom: 2rem;
-
}
-
a {
-
color: var(--accent);
-
text-decoration: none;
-
font-size: 1rem;
-
}
-
a:hover {
-
text-decoration: underline;
-
}
-
footer {
-
margin-top: 2rem;
-
padding-top: 1.5rem;
-
border-top: 1px solid var(--border);
-
text-align: center;
-
font-size: 0.875rem;
-
opacity: 0.7;
-
color: var(--foreground);
-
}
-
footer a {
-
color: var(--accent);
-
text-decoration: none;
-
display: inline;
-
}
-
footer a:hover {
-
text-decoration: underline;
-
}
-
</style>
-
</head>
-
<body>
-
<div>
-
<h1>404</h1>
-
<h2>Page not found</h2>
-
<p>The page you're looking for doesn't exist.</p>
-
<a href="/">← Back to home</a>
-
</div>
-
<footer>
-
Hosted on <a href="https://wisp.place" target="_blank" rel="noopener">wisp.place</a> - Made by <a href="https://bsky.app/profile/nekomimi.pet" target="_blank" rel="noopener">@nekomimi.pet</a>
-
</footer>
-
</body>
-
</html>`;
-
return html;
-
}
-
-
/**
-
* Generate directory listing HTML
-
*/
-
function generateDirectoryListing(path: string, entries: Array<{name: string, isDirectory: boolean}>): string {
-
const title = path || 'Index';
-
-
// Sort: directories first, then files, alphabetically within each group
-
const sortedEntries = [...entries].sort((a, b) => {
-
if (a.isDirectory && !b.isDirectory) return -1;
-
if (!a.isDirectory && b.isDirectory) return 1;
-
return a.name.localeCompare(b.name);
-
});
-
-
const html = `<!DOCTYPE html>
-
<html>
-
<head>
-
<meta charset="utf-8">
-
<meta name="viewport" content="width=device-width, initial-scale=1">
-
<title>Index of /${path}</title>
-
<style>
-
@media (prefers-color-scheme: light) {
-
:root {
-
/* Warm beige background */
-
--background: oklch(0.90 0.012 35);
-
/* Very dark brown text */
-
--foreground: oklch(0.18 0.01 30);
-
--border: oklch(0.75 0.015 30);
-
/* Bright pink accent for links */
-
--accent: oklch(0.78 0.15 345);
-
/* Lavender for folders */
-
--folder: oklch(0.60 0.12 295);
-
--icon: oklch(0.28 0.01 30);
-
}
-
}
-
@media (prefers-color-scheme: dark) {
-
:root {
-
/* Slate violet background */
-
--background: oklch(0.23 0.015 285);
-
/* Light gray text */
-
--foreground: oklch(0.90 0.005 285);
-
/* Subtle borders */
-
--border: oklch(0.38 0.02 285);
-
/* Soft pink accent */
-
--accent: oklch(0.85 0.08 5);
-
/* Lavender for folders */
-
--folder: oklch(0.70 0.10 295);
-
--icon: oklch(0.85 0.005 285);
-
}
-
}
-
body {
-
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
-
background: var(--background);
-
color: var(--foreground);
-
padding: 2rem;
-
max-width: 800px;
-
margin: 0 auto;
-
}
-
h1 {
-
font-size: 1.5rem;
-
margin-bottom: 2rem;
-
padding-bottom: 0.5rem;
-
border-bottom: 1px solid var(--border);
-
}
-
ul {
-
list-style: none;
-
padding: 0;
-
}
-
li {
-
padding: 0.5rem 0;
-
border-bottom: 1px solid var(--border);
-
}
-
li:last-child {
-
border-bottom: none;
-
}
-
li a {
-
color: var(--accent);
-
text-decoration: none;
-
display: flex;
-
align-items: center;
-
gap: 0.75rem;
-
}
-
li a:hover {
-
text-decoration: underline;
-
}
-
.folder {
-
color: var(--folder);
-
font-weight: 600;
-
}
-
.file {
-
color: var(--accent);
-
}
-
.folder::before,
-
.file::before,
-
.parent::before {
-
content: "";
-
display: inline-block;
-
width: 1.25em;
-
height: 1.25em;
-
background-color: var(--icon);
-
flex-shrink: 0;
-
-webkit-mask-size: contain;
-
mask-size: contain;
-
-webkit-mask-repeat: no-repeat;
-
mask-repeat: no-repeat;
-
-webkit-mask-position: center;
-
mask-position: center;
-
}
-
.folder::before {
-
-webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M64 15v37a5.006 5.006 0 0 1-5 5H5a5.006 5.006 0 0 1-5-5V12a5.006 5.006 0 0 1 5-5h14.116a6.966 6.966 0 0 1 5.466 2.627l5 6.247A2.983 2.983 0 0 0 31.922 17H59a1 1 0 0 1 0 2H31.922a4.979 4.979 0 0 1-3.9-1.876l-5-6.247A4.976 4.976 0 0 0 19.116 9H5a3 3 0 0 0-3 3v40a3 3 0 0 0 3 3h54a3 3 0 0 0 3-3V15a3 3 0 0 0-3-3H30a1 1 0 0 1 0-2h29a5.006 5.006 0 0 1 5 5z"/></svg>');
-
mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M64 15v37a5.006 5.006 0 0 1-5 5H5a5.006 5.006 0 0 1-5-5V12a5.006 5.006 0 0 1 5-5h14.116a6.966 6.966 0 0 1 5.466 2.627l5 6.247A2.983 2.983 0 0 0 31.922 17H59a1 1 0 0 1 0 2H31.922a4.979 4.979 0 0 1-3.9-1.876l-5-6.247A4.976 4.976 0 0 0 19.116 9H5a3 3 0 0 0-3 3v40a3 3 0 0 0 3 3h54a3 3 0 0 0 3-3V15a3 3 0 0 0-3-3H30a1 1 0 0 1 0-2h29a5.006 5.006 0 0 1 5 5z"/></svg>');
-
}
-
.file::before {
-
-webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><g><path d="M18 8.28a.59.59 0 0 0-.13-.18l-4-3.9h-.05a.41.41 0 0 0-.15-.2.41.41 0 0 0-.19 0h-9a.5.5 0 0 0-.5.5v19a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5V8.43a.58.58 0 0 0 .02-.15zM16.3 8H14V5.69zM5 23V5h8v3.5a.49.49 0 0 0 .15.36.5.5 0 0 0 .35.14l3.5-.06V23z"/><path d="M20.5 1h-13a.5.5 0 0 0-.5.5V3a.5.5 0 0 0 1 0V2h12v18h-1a.5.5 0 0 0 0 1h1.5a.5.5 0 0 0 .5-.5v-19a.5.5 0 0 0-.5-.5z"/><path d="M7.5 8h3a.5.5 0 0 0 0-1h-3a.5.5 0 0 0 0 1zM7.5 11h4a.5.5 0 0 0 0-1h-4a.5.5 0 0 0 0 1zM13.5 13h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 16h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 19h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1z"/></g></svg>');
-
mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><g><path d="M18 8.28a.59.59 0 0 0-.13-.18l-4-3.9h-.05a.41.41 0 0 0-.15-.2.41.41 0 0 0-.19 0h-9a.5.5 0 0 0-.5.5v19a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5V8.43a.58.58 0 0 0 .02-.15zM16.3 8H14V5.69zM5 23V5h8v3.5a.49.49 0 0 0 .15.36.5.5 0 0 0 .35.14l3.5-.06V23z"/><path d="M20.5 1h-13a.5.5 0 0 0-.5.5V3a.5.5 0 0 0 1 0V2h12v18h-1a.5.5 0 0 0 0 1h1.5a.5.5 0 0 0 .5-.5v-19a.5.5 0 0 0-.5-.5z"/><path d="M7.5 8h3a.5.5 0 0 0 0-1h-3a.5.5 0 0 0 0 1zM7.5 11h4a.5.5 0 0 0 0-1h-4a.5.5 0 0 0 0 1zM13.5 13h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 16h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 19h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1z"/></g></svg>');
-
}
-
.parent::before {
-
-webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>');
-
mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>');
-
}
-
footer {
-
margin-top: 2rem;
-
padding-top: 1.5rem;
-
border-top: 1px solid var(--border);
-
text-align: center;
-
font-size: 0.875rem;
-
opacity: 0.7;
-
color: var(--foreground);
-
}
-
footer a {
-
color: var(--accent);
-
text-decoration: none;
-
display: inline;
-
}
-
footer a:hover {
-
text-decoration: underline;
-
}
-
</style>
-
</head>
-
<body>
-
<h1>Index of /${path}</h1>
-
<ul>
-
${path ? '<li><a href="../" class="parent">../</a></li>' : ''}
-
${sortedEntries.map(e =>
-
`<li><a href="${e.name}${e.isDirectory ? '/' : ''}" class="${e.isDirectory ? 'folder' : 'file'}">${e.name}${e.isDirectory ? '/' : ''}</a></li>`
-
).join('\n ')}
-
</ul>
-
<footer>
-
Hosted on <a href="https://wisp.place" target="_blank" rel="noopener">wisp.place</a> - Made by <a href="https://bsky.app/profile/nekomimi.pet" target="_blank" rel="noopener">@nekomimi.pet</a>
-
</footer>
-
</body>
-
</html>`;
-
return html;
-
}
-
-
/**
-
* Validate site name (rkey) to prevent injection attacks
-
* Must match AT Protocol rkey format
-
*/
-
function isValidRkey(rkey: string): boolean {
-
if (!rkey || typeof rkey !== 'string') return false;
-
if (rkey.length < 1 || rkey.length > 512) return false;
-
if (rkey === '.' || rkey === '..') return false;
-
if (rkey.includes('/') || rkey.includes('\\') || rkey.includes('\0')) return false;
-
const validRkeyPattern = /^[a-zA-Z0-9._~:-]+$/;
-
return validRkeyPattern.test(rkey);
-
}
-
-
/**
-
* Async file existence check
-
*/
-
async function fileExists(path: string): Promise<boolean> {
-
try {
-
await access(path);
-
return true;
-
} catch {
-
return false;
-
}
-
}
-
-
/**
-
* Return a response indicating the site is being updated
-
*/
-
function siteUpdatingResponse(): Response {
-
const html = `<!DOCTYPE html>
-
<html>
-
<head>
-
<meta charset="utf-8">
-
<meta name="viewport" content="width=device-width, initial-scale=1">
-
<title>Site Updating</title>
-
<style>
-
@media (prefers-color-scheme: light) {
-
:root {
-
--background: oklch(0.90 0.012 35);
-
--foreground: oklch(0.18 0.01 30);
-
--primary: oklch(0.35 0.02 35);
-
--accent: oklch(0.78 0.15 345);
-
}
-
}
-
@media (prefers-color-scheme: dark) {
-
:root {
-
--background: oklch(0.23 0.015 285);
-
--foreground: oklch(0.90 0.005 285);
-
--primary: oklch(0.70 0.10 295);
-
--accent: oklch(0.85 0.08 5);
-
}
-
}
-
body {
-
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
-
display: flex;
-
align-items: center;
-
justify-content: center;
-
min-height: 100vh;
-
margin: 0;
-
background: var(--background);
-
color: var(--foreground);
-
}
-
.container {
-
text-align: center;
-
padding: 2rem;
-
max-width: 500px;
-
}
-
h1 {
-
font-size: 2.5rem;
-
margin-bottom: 1rem;
-
font-weight: 600;
-
color: var(--primary);
-
}
-
p {
-
font-size: 1.25rem;
-
opacity: 0.8;
-
margin-bottom: 2rem;
-
color: var(--foreground);
-
}
-
.spinner {
-
border: 4px solid var(--accent);
-
border-radius: 50%;
-
border-top: 4px solid var(--primary);
-
width: 40px;
-
height: 40px;
-
animation: spin 1s linear infinite;
-
margin: 0 auto;
-
}
-
@keyframes spin {
-
0% { transform: rotate(0deg); }
-
100% { transform: rotate(360deg); }
-
}
-
</style>
-
<meta http-equiv="refresh" content="3">
-
</head>
-
<body>
-
<div class="container">
-
<h1>Site Updating</h1>
-
<p>This site is undergoing an update right now. Check back in a moment...</p>
-
<div class="spinner"></div>
-
</div>
-
</body>
-
</html>`;
-
-
return new Response(html, {
-
status: 503,
-
headers: {
-
'Content-Type': 'text/html; charset=utf-8',
-
'Cache-Control': 'no-store, no-cache, must-revalidate',
-
'Retry-After': '3',
-
},
-
});
-
}
-
-
// Cache for redirect rules (per site)
-
const redirectRulesCache = new Map<string, RedirectRule[]>();
-
-
/**
-
* Clear redirect rules cache for a specific site
-
* Should be called when a site is updated/recached
-
*/
-
export function clearRedirectRulesCache(did: string, rkey: string) {
-
const cacheKey = `${did}:${rkey}`;
-
redirectRulesCache.delete(cacheKey);
-
}
-
-
// Helper to serve files from cache
-
async function serveFromCache(
-
did: string,
-
rkey: string,
-
filePath: string,
-
fullUrl?: string,
-
headers?: Record<string, string>
-
) {
-
// Load settings for this site
-
const settings = await getCachedSettings(did, rkey);
-
const indexFiles = getIndexFiles(settings);
-
-
// Check for redirect rules first (_redirects wins over settings)
-
const redirectCacheKey = `${did}:${rkey}`;
-
let redirectRules = redirectRulesCache.get(redirectCacheKey);
-
-
if (redirectRules === undefined) {
-
// Load rules for the first time
-
redirectRules = await loadRedirectRules(did, rkey);
-
redirectRulesCache.set(redirectCacheKey, redirectRules);
-
}
-
-
// Apply redirect rules if any exist
-
if (redirectRules.length > 0) {
-
const requestPath = '/' + (filePath || '');
-
const queryParams = fullUrl ? parseQueryString(fullUrl) : {};
-
const cookies = parseCookies(headers?.['cookie']);
-
-
const redirectMatch = matchRedirectRule(requestPath, redirectRules, {
-
queryParams,
-
headers,
-
cookies,
-
});
-
-
if (redirectMatch) {
-
const { rule, targetPath, status } = redirectMatch;
-
-
// If not forced, check if the requested file exists before redirecting
-
if (!rule.force) {
-
// Build the expected file path
-
let checkPath = filePath || indexFiles[0];
-
if (checkPath.endsWith('/')) {
-
checkPath += indexFiles[0];
-
}
-
-
const cachedFile = getCachedFilePath(did, rkey, checkPath);
-
const fileExistsOnDisk = await fileExists(cachedFile);
-
-
// If file exists and redirect is not forced, serve the file normally
-
if (fileExistsOnDisk) {
-
return serveFileInternal(did, rkey, filePath, settings);
-
}
-
}
-
-
// Handle different status codes
-
if (status === 200) {
-
// Rewrite: serve different content but keep URL the same
-
// Remove leading slash for internal path resolution
-
const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
-
return serveFileInternal(did, rkey, rewritePath, settings);
-
} else if (status === 301 || status === 302) {
-
// External redirect: change the URL
-
return new Response(null, {
-
status,
-
headers: {
-
'Location': targetPath,
-
'Cache-Control': status === 301 ? 'public, max-age=31536000' : 'public, max-age=0',
-
},
-
});
-
} else if (status === 404) {
-
// Custom 404 page from _redirects (wins over settings.custom404)
-
const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
-
const response = await serveFileInternal(did, rkey, custom404Path, settings);
-
// Override status to 404
-
return new Response(response.body, {
-
status: 404,
-
headers: response.headers,
-
});
-
}
-
}
-
}
-
-
// No redirect matched, serve normally with settings
-
return serveFileInternal(did, rkey, filePath, settings);
-
}
-
-
// Internal function to serve a file (used by both normal serving and rewrites)
-
async function serveFileInternal(did: string, rkey: string, filePath: string, settings: WispSettings | null = null) {
-
// Check if site is currently being cached - if so, return updating response
-
if (isSiteBeingCached(did, rkey)) {
-
return siteUpdatingResponse();
-
}
-
-
const indexFiles = getIndexFiles(settings);
-
-
// Normalize the request path (keep empty for root, remove trailing slash for others)
-
let requestPath = filePath || '';
-
if (requestPath.endsWith('/') && requestPath.length > 1) {
-
requestPath = requestPath.slice(0, -1);
-
}
-
-
// Check if this path is a directory first
-
const directoryPath = getCachedFilePath(did, rkey, requestPath);
-
if (await fileExists(directoryPath)) {
-
const { stat, readdir } = await import('fs/promises');
-
try {
-
const stats = await stat(directoryPath);
-
if (stats.isDirectory()) {
-
// It's a directory, try each index file in order
-
for (const indexFile of indexFiles) {
-
const indexPath = requestPath ? `${requestPath}/${indexFile}` : indexFile;
-
const indexFilePath = getCachedFilePath(did, rkey, indexPath);
-
if (await fileExists(indexFilePath)) {
-
return serveFileInternal(did, rkey, indexPath, settings);
-
}
-
}
-
// No index file found - check if directory listing is enabled
-
if (settings?.directoryListing) {
-
const { stat } = await import('fs/promises');
-
const entries = await readdir(directoryPath);
-
// Filter out .meta files and other hidden files
-
const visibleEntries = entries.filter(entry => !entry.endsWith('.meta') && entry !== '.metadata.json');
-
-
// Check which entries are directories
-
const entriesWithType = await Promise.all(
-
visibleEntries.map(async (name) => {
-
try {
-
const entryPath = `${directoryPath}/${name}`;
-
const stats = await stat(entryPath);
-
return { name, isDirectory: stats.isDirectory() };
-
} catch {
-
return { name, isDirectory: false };
-
}
-
})
-
);
-
-
const html = generateDirectoryListing(requestPath, entriesWithType);
-
return new Response(html, {
-
headers: {
-
'Content-Type': 'text/html; charset=utf-8',
-
'Cache-Control': 'public, max-age=300',
-
},
-
});
-
}
-
// Fall through to 404/SPA handling
-
}
-
} catch (err) {
-
// If stat fails, continue with normal flow
-
}
-
}
-
-
// Not a directory, try to serve as a file
-
const fileRequestPath = requestPath || indexFiles[0];
-
const cacheKey = getCacheKey(did, rkey, fileRequestPath);
-
const cachedFile = getCachedFilePath(did, rkey, fileRequestPath);
-
-
// Check in-memory cache first
-
let content = fileCache.get(cacheKey);
-
let meta = metadataCache.get(cacheKey);
-
-
if (!content && await fileExists(cachedFile)) {
-
// Read from disk and cache
-
content = await readFile(cachedFile);
-
fileCache.set(cacheKey, content, content.length);
-
-
const metaFile = `${cachedFile}.meta`;
-
if (await fileExists(metaFile)) {
-
const metaJson = await readFile(metaFile, 'utf-8');
-
meta = JSON.parse(metaJson);
-
metadataCache.set(cacheKey, meta!, JSON.stringify(meta).length);
-
}
-
}
-
-
if (content) {
-
// Build headers with caching
-
const headers: Record<string, string> = {};
-
-
if (meta && meta.encoding === 'gzip' && meta.mimeType) {
-
const shouldServeCompressed = shouldCompressMimeType(meta.mimeType);
-
-
if (!shouldServeCompressed) {
-
// Verify content is actually gzipped before attempting decompression
-
const isGzipped = content.length >= 2 && content[0] === 0x1f && content[1] === 0x8b;
-
if (isGzipped) {
-
const { gunzipSync } = await import('zlib');
-
const decompressed = gunzipSync(content);
-
headers['Content-Type'] = meta.mimeType;
-
headers['Cache-Control'] = 'public, max-age=31536000, immutable';
-
applyCustomHeaders(headers, fileRequestPath, settings);
-
return new Response(decompressed, { headers });
-
} else {
-
// Meta says gzipped but content isn't - serve as-is
-
console.warn(`File ${filePath} has gzip encoding in meta but content lacks gzip magic bytes`);
-
headers['Content-Type'] = meta.mimeType;
-
headers['Cache-Control'] = 'public, max-age=31536000, immutable';
-
applyCustomHeaders(headers, fileRequestPath, settings);
-
return new Response(content, { headers });
-
}
-
}
-
-
headers['Content-Type'] = meta.mimeType;
-
headers['Content-Encoding'] = 'gzip';
-
headers['Cache-Control'] = meta.mimeType.startsWith('text/html')
-
? 'public, max-age=300'
-
: 'public, max-age=31536000, immutable';
-
applyCustomHeaders(headers, fileRequestPath, settings);
-
return new Response(content, { headers });
-
}
-
-
// Non-compressed files
-
const mimeType = lookup(cachedFile) || 'application/octet-stream';
-
headers['Content-Type'] = mimeType;
-
headers['Cache-Control'] = mimeType.startsWith('text/html')
-
? 'public, max-age=300'
-
: 'public, max-age=31536000, immutable';
-
applyCustomHeaders(headers, fileRequestPath, settings);
-
return new Response(content, { headers });
-
}
-
-
// Try index files for directory-like paths
-
if (!fileRequestPath.includes('.')) {
-
for (const indexFileName of indexFiles) {
-
const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName;
-
const indexCacheKey = getCacheKey(did, rkey, indexPath);
-
const indexFile = getCachedFilePath(did, rkey, indexPath);
-
-
let indexContent = fileCache.get(indexCacheKey);
-
let indexMeta = metadataCache.get(indexCacheKey);
-
-
if (!indexContent && await fileExists(indexFile)) {
-
indexContent = await readFile(indexFile);
-
fileCache.set(indexCacheKey, indexContent, indexContent.length);
-
-
const indexMetaFile = `${indexFile}.meta`;
-
if (await fileExists(indexMetaFile)) {
-
const metaJson = await readFile(indexMetaFile, 'utf-8');
-
indexMeta = JSON.parse(metaJson);
-
metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length);
-
}
-
}
-
-
if (indexContent) {
-
const headers: Record<string, string> = {
-
'Content-Type': 'text/html; charset=utf-8',
-
'Cache-Control': 'public, max-age=300',
-
};
-
-
if (indexMeta && indexMeta.encoding === 'gzip') {
-
headers['Content-Encoding'] = 'gzip';
-
}
-
-
applyCustomHeaders(headers, indexPath, settings);
-
return new Response(indexContent, { headers });
-
}
-
}
-
}
-
-
// Try clean URLs: /about -> /about.html
-
if (settings?.cleanUrls && !fileRequestPath.includes('.')) {
-
const htmlPath = `${fileRequestPath}.html`;
-
const htmlFile = getCachedFilePath(did, rkey, htmlPath);
-
if (await fileExists(htmlFile)) {
-
return serveFileInternal(did, rkey, htmlPath, settings);
-
}
-
-
// Also try /about/index.html
-
for (const indexFileName of indexFiles) {
-
const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName;
-
const indexFile = getCachedFilePath(did, rkey, indexPath);
-
if (await fileExists(indexFile)) {
-
return serveFileInternal(did, rkey, indexPath, settings);
-
}
-
}
-
}
-
-
// SPA mode: serve SPA file for all non-existing routes (wins over custom404 but loses to _redirects)
-
if (settings?.spaMode) {
-
const spaFile = settings.spaMode;
-
const spaFilePath = getCachedFilePath(did, rkey, spaFile);
-
if (await fileExists(spaFilePath)) {
-
return serveFileInternal(did, rkey, spaFile, settings);
-
}
-
}
-
-
// Custom 404: serve custom 404 file if configured (wins conflict battle)
-
if (settings?.custom404) {
-
const custom404File = settings.custom404;
-
const custom404Path = getCachedFilePath(did, rkey, custom404File);
-
if (await fileExists(custom404Path)) {
-
const response = await serveFileInternal(did, rkey, custom404File, settings);
-
// Override status to 404
-
return new Response(response.body, {
-
status: 404,
-
headers: response.headers,
-
});
-
}
-
}
-
-
// Autodetect 404 pages (GitHub Pages: 404.html, Neocities/Nekoweb: not_found.html)
-
const auto404Pages = ['404.html', 'not_found.html'];
-
for (const auto404Page of auto404Pages) {
-
const auto404Path = getCachedFilePath(did, rkey, auto404Page);
-
if (await fileExists(auto404Path)) {
-
const response = await serveFileInternal(did, rkey, auto404Page, settings);
-
// Override status to 404
-
return new Response(response.body, {
-
status: 404,
-
headers: response.headers,
-
});
-
}
-
}
-
-
// Directory listing fallback: if enabled, show root directory listing on 404
-
if (settings?.directoryListing) {
-
const rootPath = getCachedFilePath(did, rkey, '');
-
if (await fileExists(rootPath)) {
-
const { stat, readdir } = await import('fs/promises');
-
try {
-
const stats = await stat(rootPath);
-
if (stats.isDirectory()) {
-
const entries = await readdir(rootPath);
-
// Filter out .meta files and metadata
-
const visibleEntries = entries.filter(entry =>
-
!entry.endsWith('.meta') && entry !== '.metadata.json'
-
);
-
-
// Check which entries are directories
-
const entriesWithType = await Promise.all(
-
visibleEntries.map(async (name) => {
-
try {
-
const entryPath = `${rootPath}/${name}`;
-
const entryStats = await stat(entryPath);
-
return { name, isDirectory: entryStats.isDirectory() };
-
} catch {
-
return { name, isDirectory: false };
-
}
-
})
-
);
-
-
const html = generateDirectoryListing('', entriesWithType);
-
return new Response(html, {
-
status: 404,
-
headers: {
-
'Content-Type': 'text/html; charset=utf-8',
-
'Cache-Control': 'public, max-age=300',
-
},
-
});
-
}
-
} catch (err) {
-
// If directory listing fails, fall through to 404
-
}
-
}
-
}
-
-
// Default styled 404 page
-
const html = generate404Page();
-
return new Response(html, {
-
status: 404,
-
headers: {
-
'Content-Type': 'text/html; charset=utf-8',
-
'Cache-Control': 'public, max-age=300',
-
},
-
});
-
}
-
-
// Helper to serve files from cache with HTML path rewriting for sites.wisp.place routes
-
async function serveFromCacheWithRewrite(
-
did: string,
-
rkey: string,
-
filePath: string,
-
basePath: string,
-
fullUrl?: string,
-
headers?: Record<string, string>
-
) {
-
// Load settings for this site
-
const settings = await getCachedSettings(did, rkey);
-
const indexFiles = getIndexFiles(settings);
-
-
// Check for redirect rules first (_redirects wins over settings)
-
const redirectCacheKey = `${did}:${rkey}`;
-
let redirectRules = redirectRulesCache.get(redirectCacheKey);
-
-
if (redirectRules === undefined) {
-
// Load rules for the first time
-
redirectRules = await loadRedirectRules(did, rkey);
-
redirectRulesCache.set(redirectCacheKey, redirectRules);
-
}
-
-
// Apply redirect rules if any exist
-
if (redirectRules.length > 0) {
-
const requestPath = '/' + (filePath || '');
-
const queryParams = fullUrl ? parseQueryString(fullUrl) : {};
-
const cookies = parseCookies(headers?.['cookie']);
-
-
const redirectMatch = matchRedirectRule(requestPath, redirectRules, {
-
queryParams,
-
headers,
-
cookies,
-
});
-
-
if (redirectMatch) {
-
const { rule, targetPath, status } = redirectMatch;
-
-
// If not forced, check if the requested file exists before redirecting
-
if (!rule.force) {
-
// Build the expected file path
-
let checkPath = filePath || indexFiles[0];
-
if (checkPath.endsWith('/')) {
-
checkPath += indexFiles[0];
-
}
-
-
const cachedFile = getCachedFilePath(did, rkey, checkPath);
-
const fileExistsOnDisk = await fileExists(cachedFile);
-
-
// If file exists and redirect is not forced, serve the file normally
-
if (fileExistsOnDisk) {
-
return serveFileInternalWithRewrite(did, rkey, filePath, basePath, settings);
-
}
-
}
-
-
// Handle different status codes
-
if (status === 200) {
-
// Rewrite: serve different content but keep URL the same
-
const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
-
return serveFileInternalWithRewrite(did, rkey, rewritePath, basePath, settings);
-
} else if (status === 301 || status === 302) {
-
// External redirect: change the URL
-
// For sites.wisp.place, we need to adjust the target path to include the base path
-
// unless it's an absolute URL
-
let redirectTarget = targetPath;
-
if (!targetPath.startsWith('http://') && !targetPath.startsWith('https://')) {
-
redirectTarget = basePath + (targetPath.startsWith('/') ? targetPath.slice(1) : targetPath);
-
}
-
return new Response(null, {
-
status,
-
headers: {
-
'Location': redirectTarget,
-
'Cache-Control': status === 301 ? 'public, max-age=31536000' : 'public, max-age=0',
-
},
-
});
-
} else if (status === 404) {
-
// Custom 404 page from _redirects (wins over settings.custom404)
-
const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
-
const response = await serveFileInternalWithRewrite(did, rkey, custom404Path, basePath, settings);
-
// Override status to 404
-
return new Response(response.body, {
-
status: 404,
-
headers: response.headers,
-
});
-
}
-
}
-
}
-
-
// No redirect matched, serve normally with settings
-
return serveFileInternalWithRewrite(did, rkey, filePath, basePath, settings);
-
}
-
-
// Internal function to serve a file with rewriting
-
async function serveFileInternalWithRewrite(did: string, rkey: string, filePath: string, basePath: string, settings: WispSettings | null = null) {
-
// Check if site is currently being cached - if so, return updating response
-
if (isSiteBeingCached(did, rkey)) {
-
return siteUpdatingResponse();
-
}
-
-
const indexFiles = getIndexFiles(settings);
-
-
// Normalize the request path (keep empty for root, remove trailing slash for others)
-
let requestPath = filePath || '';
-
if (requestPath.endsWith('/') && requestPath.length > 1) {
-
requestPath = requestPath.slice(0, -1);
-
}
-
-
// Check if this path is a directory first
-
const directoryPath = getCachedFilePath(did, rkey, requestPath);
-
if (await fileExists(directoryPath)) {
-
const { stat, readdir } = await import('fs/promises');
-
try {
-
const stats = await stat(directoryPath);
-
if (stats.isDirectory()) {
-
// It's a directory, try each index file in order
-
for (const indexFile of indexFiles) {
-
const indexPath = requestPath ? `${requestPath}/${indexFile}` : indexFile;
-
const indexFilePath = getCachedFilePath(did, rkey, indexPath);
-
if (await fileExists(indexFilePath)) {
-
return serveFileInternalWithRewrite(did, rkey, indexPath, basePath, settings);
-
}
-
}
-
// No index file found - check if directory listing is enabled
-
if (settings?.directoryListing) {
-
const { stat } = await import('fs/promises');
-
const entries = await readdir(directoryPath);
-
// Filter out .meta files and other hidden files
-
const visibleEntries = entries.filter(entry => !entry.endsWith('.meta') && entry !== '.metadata.json');
-
-
// Check which entries are directories
-
const entriesWithType = await Promise.all(
-
visibleEntries.map(async (name) => {
-
try {
-
const entryPath = `${directoryPath}/${name}`;
-
const stats = await stat(entryPath);
-
return { name, isDirectory: stats.isDirectory() };
-
} catch {
-
return { name, isDirectory: false };
-
}
-
})
-
);
-
-
const html = generateDirectoryListing(requestPath, entriesWithType);
-
return new Response(html, {
-
headers: {
-
'Content-Type': 'text/html; charset=utf-8',
-
'Cache-Control': 'public, max-age=300',
-
},
-
});
-
}
-
// Fall through to 404/SPA handling
-
}
-
} catch (err) {
-
// If stat fails, continue with normal flow
-
}
-
}
-
-
// Not a directory, try to serve as a file
-
const fileRequestPath = requestPath || indexFiles[0];
-
const cacheKey = getCacheKey(did, rkey, fileRequestPath);
-
const cachedFile = getCachedFilePath(did, rkey, fileRequestPath);
-
-
// Check for rewritten HTML in cache first (if it's HTML)
-
const mimeTypeGuess = lookup(fileRequestPath) || 'application/octet-stream';
-
if (isHtmlContent(fileRequestPath, mimeTypeGuess)) {
-
const rewrittenKey = getCacheKey(did, rkey, fileRequestPath, `rewritten:${basePath}`);
-
const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey);
-
if (rewrittenContent) {
-
const headers: Record<string, string> = {
-
'Content-Type': 'text/html; charset=utf-8',
-
'Content-Encoding': 'gzip',
-
'Cache-Control': 'public, max-age=300',
-
};
-
applyCustomHeaders(headers, fileRequestPath, settings);
-
return new Response(rewrittenContent, { headers });
-
}
-
}
-
-
// Check in-memory file cache
-
let content = fileCache.get(cacheKey);
-
let meta = metadataCache.get(cacheKey);
-
-
if (!content && await fileExists(cachedFile)) {
-
// Read from disk and cache
-
content = await readFile(cachedFile);
-
fileCache.set(cacheKey, content, content.length);
-
-
const metaFile = `${cachedFile}.meta`;
-
if (await fileExists(metaFile)) {
-
const metaJson = await readFile(metaFile, 'utf-8');
-
meta = JSON.parse(metaJson);
-
metadataCache.set(cacheKey, meta!, JSON.stringify(meta).length);
-
}
-
}
-
-
if (content) {
-
const mimeType = meta?.mimeType || lookup(cachedFile) || 'application/octet-stream';
-
const isGzipped = meta?.encoding === 'gzip';
-
-
// Check if this is HTML content that needs rewriting
-
if (isHtmlContent(fileRequestPath, mimeType)) {
-
let htmlContent: string;
-
if (isGzipped) {
-
// Verify content is actually gzipped
-
const hasGzipMagic = content.length >= 2 && content[0] === 0x1f && content[1] === 0x8b;
-
if (hasGzipMagic) {
-
const { gunzipSync } = await import('zlib');
-
htmlContent = gunzipSync(content).toString('utf-8');
-
} else {
-
console.warn(`File ${fileRequestPath} marked as gzipped but lacks magic bytes, serving as-is`);
-
htmlContent = content.toString('utf-8');
-
}
-
} else {
-
htmlContent = content.toString('utf-8');
-
}
-
const rewritten = rewriteHtmlPaths(htmlContent, basePath, fileRequestPath);
-
-
// Recompress and cache the rewritten HTML
-
const { gzipSync } = await import('zlib');
-
const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8'));
-
-
const rewrittenKey = getCacheKey(did, rkey, fileRequestPath, `rewritten:${basePath}`);
-
rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length);
-
-
const htmlHeaders: Record<string, string> = {
-
'Content-Type': 'text/html; charset=utf-8',
-
'Content-Encoding': 'gzip',
-
'Cache-Control': 'public, max-age=300',
-
};
-
applyCustomHeaders(htmlHeaders, fileRequestPath, settings);
-
return new Response(recompressed, { headers: htmlHeaders });
-
}
-
-
// Non-HTML files: serve as-is
-
const headers: Record<string, string> = {
-
'Content-Type': mimeType,
-
'Cache-Control': 'public, max-age=31536000, immutable',
-
};
-
-
if (isGzipped) {
-
const shouldServeCompressed = shouldCompressMimeType(mimeType);
-
if (!shouldServeCompressed) {
-
// Verify content is actually gzipped
-
const hasGzipMagic = content.length >= 2 && content[0] === 0x1f && content[1] === 0x8b;
-
if (hasGzipMagic) {
-
const { gunzipSync } = await import('zlib');
-
const decompressed = gunzipSync(content);
-
applyCustomHeaders(headers, fileRequestPath, settings);
-
return new Response(decompressed, { headers });
-
} else {
-
console.warn(`File ${fileRequestPath} marked as gzipped but lacks magic bytes, serving as-is`);
-
applyCustomHeaders(headers, fileRequestPath, settings);
-
return new Response(content, { headers });
-
}
-
}
-
headers['Content-Encoding'] = 'gzip';
-
}
-
-
applyCustomHeaders(headers, fileRequestPath, settings);
-
return new Response(content, { headers });
-
}
-
-
// Try index files for directory-like paths
-
if (!fileRequestPath.includes('.')) {
-
for (const indexFileName of indexFiles) {
-
const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName;
-
const indexCacheKey = getCacheKey(did, rkey, indexPath);
-
const indexFile = getCachedFilePath(did, rkey, indexPath);
-
-
// Check for rewritten index file in cache
-
const rewrittenKey = getCacheKey(did, rkey, indexPath, `rewritten:${basePath}`);
-
const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey);
-
if (rewrittenContent) {
-
const headers: Record<string, string> = {
-
'Content-Type': 'text/html; charset=utf-8',
-
'Content-Encoding': 'gzip',
-
'Cache-Control': 'public, max-age=300',
-
};
-
applyCustomHeaders(headers, indexPath, settings);
-
return new Response(rewrittenContent, { headers });
-
}
-
-
let indexContent = fileCache.get(indexCacheKey);
-
let indexMeta = metadataCache.get(indexCacheKey);
-
-
if (!indexContent && await fileExists(indexFile)) {
-
indexContent = await readFile(indexFile);
-
fileCache.set(indexCacheKey, indexContent, indexContent.length);
-
-
const indexMetaFile = `${indexFile}.meta`;
-
if (await fileExists(indexMetaFile)) {
-
const metaJson = await readFile(indexMetaFile, 'utf-8');
-
indexMeta = JSON.parse(metaJson);
-
metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length);
-
}
-
}
-
-
if (indexContent) {
-
const isGzipped = indexMeta?.encoding === 'gzip';
-
-
let htmlContent: string;
-
if (isGzipped) {
-
// Verify content is actually gzipped
-
const hasGzipMagic = indexContent.length >= 2 && indexContent[0] === 0x1f && indexContent[1] === 0x8b;
-
if (hasGzipMagic) {
-
const { gunzipSync } = await import('zlib');
-
htmlContent = gunzipSync(indexContent).toString('utf-8');
-
} else {
-
console.warn(`Index file marked as gzipped but lacks magic bytes, serving as-is`);
-
htmlContent = indexContent.toString('utf-8');
-
}
-
} else {
-
htmlContent = indexContent.toString('utf-8');
-
}
-
const rewritten = rewriteHtmlPaths(htmlContent, basePath, indexPath);
-
-
const { gzipSync } = await import('zlib');
-
const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8'));
-
-
rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length);
-
-
const headers: Record<string, string> = {
-
'Content-Type': 'text/html; charset=utf-8',
-
'Content-Encoding': 'gzip',
-
'Cache-Control': 'public, max-age=300',
-
};
-
applyCustomHeaders(headers, indexPath, settings);
-
return new Response(recompressed, { headers });
-
}
-
}
-
}
-
-
// Try clean URLs: /about -> /about.html
-
if (settings?.cleanUrls && !fileRequestPath.includes('.')) {
-
const htmlPath = `${fileRequestPath}.html`;
-
const htmlFile = getCachedFilePath(did, rkey, htmlPath);
-
if (await fileExists(htmlFile)) {
-
return serveFileInternalWithRewrite(did, rkey, htmlPath, basePath, settings);
-
}
-
-
// Also try /about/index.html
-
for (const indexFileName of indexFiles) {
-
const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName;
-
const indexFile = getCachedFilePath(did, rkey, indexPath);
-
if (await fileExists(indexFile)) {
-
return serveFileInternalWithRewrite(did, rkey, indexPath, basePath, settings);
-
}
-
}
-
}
-
-
// SPA mode: serve SPA file for all non-existing routes
-
if (settings?.spaMode) {
-
const spaFile = settings.spaMode;
-
const spaFilePath = getCachedFilePath(did, rkey, spaFile);
-
if (await fileExists(spaFilePath)) {
-
return serveFileInternalWithRewrite(did, rkey, spaFile, basePath, settings);
-
}
-
}
-
-
// Custom 404: serve custom 404 file if configured (wins conflict battle)
-
if (settings?.custom404) {
-
const custom404File = settings.custom404;
-
const custom404Path = getCachedFilePath(did, rkey, custom404File);
-
if (await fileExists(custom404Path)) {
-
const response = await serveFileInternalWithRewrite(did, rkey, custom404File, basePath, settings);
-
// Override status to 404
-
return new Response(response.body, {
-
status: 404,
-
headers: response.headers,
-
});
-
}
-
}
-
-
// Autodetect 404 pages (GitHub Pages: 404.html, Neocities/Nekoweb: not_found.html)
-
const auto404Pages = ['404.html', 'not_found.html'];
-
for (const auto404Page of auto404Pages) {
-
const auto404Path = getCachedFilePath(did, rkey, auto404Page);
-
if (await fileExists(auto404Path)) {
-
const response = await serveFileInternalWithRewrite(did, rkey, auto404Page, basePath, settings);
-
// Override status to 404
-
return new Response(response.body, {
-
status: 404,
-
headers: response.headers,
-
});
-
}
-
}
-
-
// Directory listing fallback: if enabled, show root directory listing on 404
-
if (settings?.directoryListing) {
-
const rootPath = getCachedFilePath(did, rkey, '');
-
if (await fileExists(rootPath)) {
-
const { stat, readdir } = await import('fs/promises');
-
try {
-
const stats = await stat(rootPath);
-
if (stats.isDirectory()) {
-
const entries = await readdir(rootPath);
-
// Filter out .meta files and metadata
-
const visibleEntries = entries.filter(entry =>
-
!entry.endsWith('.meta') && entry !== '.metadata.json'
-
);
-
-
// Check which entries are directories
-
const entriesWithType = await Promise.all(
-
visibleEntries.map(async (name) => {
-
try {
-
const entryPath = `${rootPath}/${name}`;
-
const entryStats = await stat(entryPath);
-
return { name, isDirectory: entryStats.isDirectory() };
-
} catch {
-
return { name, isDirectory: false };
-
}
-
})
-
);
-
-
const html = generateDirectoryListing('', entriesWithType);
-
return new Response(html, {
-
status: 404,
-
headers: {
-
'Content-Type': 'text/html; charset=utf-8',
-
'Cache-Control': 'public, max-age=300',
-
},
-
});
-
}
-
} catch (err) {
-
// If directory listing fails, fall through to 404
-
}
-
}
-
}
-
-
// Default styled 404 page
-
const html = generate404Page();
-
return new Response(html, {
-
status: 404,
-
headers: {
-
'Content-Type': 'text/html; charset=utf-8',
-
'Cache-Control': 'public, max-age=300',
-
},
-
});
-
}
-
-
// Helper to ensure site is cached
-
async function ensureSiteCached(did: string, rkey: string): Promise<boolean> {
-
if (isCached(did, rkey)) {
-
return true;
-
}
-
-
// Fetch and cache the site
-
const siteData = await fetchSiteRecord(did, rkey);
-
if (!siteData) {
-
logger.error('Site record not found', null, { did, rkey });
-
return false;
-
}
-
-
const pdsEndpoint = await getPdsForDid(did);
-
if (!pdsEndpoint) {
-
logger.error('PDS not found for DID', null, { did });
-
return false;
-
}
-
-
// Mark site as being cached to prevent serving stale content during update
-
markSiteAsBeingCached(did, rkey);
-
-
try {
-
await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid);
-
// Clear redirect rules cache since the site was updated
-
clearRedirectRulesCache(did, rkey);
-
logger.info('Site cached successfully', { did, rkey });
-
return true;
-
} catch (err) {
-
logger.error('Failed to cache site', err, { did, rkey });
-
return false;
-
} finally {
-
// Always unmark, even if caching fails
-
unmarkSiteAsBeingCached(did, rkey);
-
}
-
}
-
-
const app = new Hono();
-
-
// Add CORS middleware - allow all origins for static site hosting
-
app.use('*', cors({
-
origin: '*',
-
allowMethods: ['GET', 'HEAD', 'OPTIONS'],
-
allowHeaders: ['Content-Type', 'Authorization'],
-
exposeHeaders: ['Content-Length', 'Content-Type', 'Content-Encoding', 'Cache-Control'],
-
maxAge: 86400, // 24 hours
-
credentials: false,
-
}));
-
-
// Add observability middleware
-
app.use('*', observabilityMiddleware('hosting-service'));
-
-
// Error handler
-
app.onError(observabilityErrorHandler('hosting-service'));
-
-
// Main site serving route
-
app.get('/*', async (c) => {
-
const url = new URL(c.req.url);
-
const hostname = c.req.header('host') || '';
-
const rawPath = url.pathname.replace(/^\//, '');
-
const path = sanitizePath(rawPath);
-
-
// Check if this is sites.wisp.place subdomain (strip port for comparison)
-
const hostnameWithoutPort = hostname.split(':')[0];
-
if (hostnameWithoutPort === `sites.${BASE_HOST}`) {
-
// Sanitize the path FIRST to prevent path traversal
-
const sanitizedFullPath = sanitizePath(rawPath);
-
-
// Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html
-
const pathParts = sanitizedFullPath.split('/');
-
if (pathParts.length < 2) {
-
return c.text('Invalid path format. Expected: /identifier/sitename/path', 400);
-
}
-
-
const identifier = pathParts[0];
-
const site = pathParts[1];
-
const filePath = pathParts.slice(2).join('/');
-
-
// Additional validation: identifier must be a valid DID or handle format
-
if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) {
-
return c.text('Invalid identifier', 400);
-
}
-
-
// Validate site parameter exists
-
if (!site) {
-
return c.text('Site name required', 400);
-
}
-
-
// Validate site name (rkey)
-
if (!isValidRkey(site)) {
-
return c.text('Invalid site name', 400);
-
}
-
-
// Resolve identifier to DID
-
const did = await resolveDid(identifier);
-
if (!did) {
-
return c.text('Invalid identifier', 400);
-
}
-
-
// Check if site is currently being cached - return updating response early
-
if (isSiteBeingCached(did, site)) {
-
return siteUpdatingResponse();
-
}
-
-
// Ensure site is cached
-
const cached = await ensureSiteCached(did, site);
-
if (!cached) {
-
return c.text('Site not found', 404);
-
}
-
-
// Serve with HTML path rewriting to handle absolute paths
-
const basePath = `/${identifier}/${site}/`;
-
const headers: Record<string, string> = {};
-
c.req.raw.headers.forEach((value, key) => {
-
headers[key.toLowerCase()] = value;
-
});
-
return serveFromCacheWithRewrite(did, site, filePath, basePath, c.req.url, headers);
-
}
-
-
// Check if this is a DNS hash subdomain
-
const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/);
-
if (dnsMatch) {
-
const hash = dnsMatch[1];
-
const baseDomain = dnsMatch[2];
-
-
if (!hash) {
-
return c.text('Invalid DNS hash', 400);
-
}
-
-
if (baseDomain !== BASE_HOST) {
-
return c.text('Invalid base domain', 400);
-
}
-
-
const customDomain = await getCustomDomainByHash(hash);
-
if (!customDomain) {
-
return c.text('Custom domain not found or not verified', 404);
-
}
-
-
if (!customDomain.rkey) {
-
return c.text('Domain not mapped to a site', 404);
-
}
-
-
const rkey = customDomain.rkey;
-
if (!isValidRkey(rkey)) {
-
return c.text('Invalid site configuration', 500);
-
}
-
-
// Check if site is currently being cached - return updating response early
-
if (isSiteBeingCached(customDomain.did, rkey)) {
-
return siteUpdatingResponse();
-
}
-
-
const cached = await ensureSiteCached(customDomain.did, rkey);
-
if (!cached) {
-
return c.text('Site not found', 404);
-
}
-
-
const headers: Record<string, string> = {};
-
c.req.raw.headers.forEach((value, key) => {
-
headers[key.toLowerCase()] = value;
-
});
-
return serveFromCache(customDomain.did, rkey, path, c.req.url, headers);
-
}
-
-
// Route 2: Registered subdomains - /*.wisp.place/*
-
if (hostname.endsWith(`.${BASE_HOST}`)) {
-
const domainInfo = await getWispDomain(hostname);
-
if (!domainInfo) {
-
return c.text('Subdomain not registered', 404);
-
}
-
-
if (!domainInfo.rkey) {
-
return c.text('Domain not mapped to a site', 404);
-
}
-
-
const rkey = domainInfo.rkey;
-
if (!isValidRkey(rkey)) {
-
return c.text('Invalid site configuration', 500);
-
}
-
-
// Check if site is currently being cached - return updating response early
-
if (isSiteBeingCached(domainInfo.did, rkey)) {
-
return siteUpdatingResponse();
-
}
-
-
const cached = await ensureSiteCached(domainInfo.did, rkey);
-
if (!cached) {
-
return c.text('Site not found', 404);
-
}
-
-
const headers: Record<string, string> = {};
-
c.req.raw.headers.forEach((value, key) => {
-
headers[key.toLowerCase()] = value;
-
});
-
return serveFromCache(domainInfo.did, rkey, path, c.req.url, headers);
-
}
-
-
// Route 1: Custom domains - /*
-
const customDomain = await getCustomDomain(hostname);
-
if (!customDomain) {
-
return c.text('Custom domain not found or not verified', 404);
-
}
-
-
if (!customDomain.rkey) {
-
return c.text('Domain not mapped to a site', 404);
-
}
-
-
const rkey = customDomain.rkey;
-
if (!isValidRkey(rkey)) {
-
return c.text('Invalid site configuration', 500);
-
}
-
-
// Check if site is currently being cached - return updating response early
-
if (isSiteBeingCached(customDomain.did, rkey)) {
-
return siteUpdatingResponse();
-
}
-
-
const cached = await ensureSiteCached(customDomain.did, rkey);
-
if (!cached) {
-
return c.text('Site not found', 404);
-
}
-
-
const headers: Record<string, string> = {};
-
c.req.raw.headers.forEach((value, key) => {
-
headers[key.toLowerCase()] = value;
-
});
-
return serveFromCache(customDomain.did, rkey, path, c.req.url, headers);
-
});
-
-
// Internal observability endpoints (for admin panel)
-
app.get('/__internal__/observability/logs', (c) => {
-
const query = c.req.query();
-
const filter: any = {};
-
if (query.level) filter.level = query.level;
-
if (query.service) filter.service = query.service;
-
if (query.search) filter.search = query.search;
-
if (query.eventType) filter.eventType = query.eventType;
-
if (query.limit) filter.limit = parseInt(query.limit as string);
-
return c.json({ logs: logCollector.getLogs(filter) });
-
});
-
-
app.get('/__internal__/observability/errors', (c) => {
-
const query = c.req.query();
-
const filter: any = {};
-
if (query.service) filter.service = query.service;
-
if (query.limit) filter.limit = parseInt(query.limit as string);
-
return c.json({ errors: errorTracker.getErrors(filter) });
-
});
-
-
app.get('/__internal__/observability/metrics', (c) => {
-
const query = c.req.query();
-
const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000;
-
const stats = metricsCollector.getStats('hosting-service', timeWindow);
-
return c.json({ stats, timeWindow });
-
});
-
-
app.get('/__internal__/observability/cache', async (c) => {
-
const { getCacheStats } = await import('./lib/cache');
-
const stats = getCacheStats();
-
return c.json({ cache: stats });
-
});
-
-
export default app;
···
+6
hosting-service/tsconfig.json apps/hosting-service/tsconfig.json
···
/* Code doesn't run in DOM */
"lib": ["es2022"],
},
"include": ["src/**/*"],
"exclude": ["node_modules", "cache", "dist"]
···
/* Code doesn't run in DOM */
"lib": ["es2022"],
+
+
/* Workspace Paths */
+
"baseUrl": ".",
+
"paths": {
+
"@wisp/*": ["../../packages/@wisp/*/src"]
+
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "cache", "dist"]
lexicons/fs.json packages/@wisp/lexicons/lexicons/fs.json
lexicons/settings.json packages/@wisp/lexicons/lexicons/settings.json
lexicons/subfs.json packages/@wisp/lexicons/lexicons/subfs.json
+15 -55
package.json
···
{
-
"name": "elysia-static",
"version": "1.0.50",
"scripts": {
"test": "bun test",
-
"dev": "bun run --watch src/index.ts",
-
"start": "bun run src/index.ts",
-
"build": "bun build --compile --target bun --outfile server src/index.ts",
-
"screenshot": "bun run scripts/screenshot-sites.ts"
-
},
-
"dependencies": {
-
"@atproto/api": "^0.17.3",
-
"@atproto/lex-cli": "^0.9.5",
-
"@atproto/oauth-client-node": "^0.3.9",
-
"@atproto/xrpc-server": "^0.9.5",
-
"@elysiajs/cors": "^1.4.0",
-
"@elysiajs/eden": "^1.4.3",
-
"@elysiajs/openapi": "^1.4.11",
-
"@elysiajs/opentelemetry": "^1.4.6",
-
"@elysiajs/static": "^1.4.2",
-
"@radix-ui/react-checkbox": "^1.3.3",
-
"@radix-ui/react-dialog": "^1.1.15",
-
"@radix-ui/react-label": "^2.1.7",
-
"@radix-ui/react-radio-group": "^1.3.8",
-
"@radix-ui/react-slot": "^1.2.3",
-
"@radix-ui/react-tabs": "^1.1.13",
-
"@tanstack/react-query": "^5.90.2",
-
"actor-typeahead": "^0.1.1",
-
"atproto-ui": "^0.11.3",
-
"class-variance-authority": "^0.7.1",
-
"clsx": "^2.1.1",
-
"elysia": "latest",
-
"iron-session": "^8.0.4",
-
"lucide-react": "^0.546.0",
-
"multiformats": "^13.4.1",
-
"prismjs": "^1.30.0",
-
"react": "^19.2.0",
-
"react-dom": "^19.2.0",
-
"tailwind-merge": "^3.3.1",
-
"tailwindcss": "4",
-
"tw-animate-css": "^1.4.0",
-
"typescript": "^5.9.3",
-
"zlib": "^1.0.5"
-
},
-
"devDependencies": {
-
"@types/react": "^19.2.2",
-
"@types/react-dom": "^19.2.1",
-
"bun-plugin-tailwind": "^0.1.2",
-
"bun-types": "latest",
-
"esbuild": "0.26.0",
-
"playwright": "^1.49.0"
-
},
-
"module": "src/index.js",
-
"trustedDependencies": [
-
"bun",
-
"cbor-extract",
-
"core-js",
-
"protobufjs"
-
]
}
···
{
+
"name": "@wisp/monorepo",
"version": "1.0.50",
+
"private": true,
+
"workspaces": [
+
"packages/@wisp/*",
+
"apps/main-app",
+
"apps/hosting-service"
+
],
"scripts": {
"test": "bun test",
+
"dev": "bun run --watch apps/main-app/src/index.ts",
+
"start": "bun run apps/main-app/src/index.ts",
+
"build": "bun build --compile --target bun --outfile server apps/main-app/src/index.ts",
+
"screenshot": "bun run apps/main-app/scripts/screenshot-sites.ts",
+
"hosting:dev": "cd apps/hosting-service && npm run dev",
+
"hosting:build": "cd apps/hosting-service && npm run build",
+
"hosting:start": "cd apps/hosting-service && npm run start"
+
}
}
+31
packages/@wisp/atproto-utils/package.json
···
···
+
{
+
"name": "@wisp/atproto-utils",
+
"version": "1.0.0",
+
"private": true,
+
"type": "module",
+
"main": "./src/index.ts",
+
"types": "./src/index.ts",
+
"exports": {
+
".": {
+
"types": "./src/index.ts",
+
"default": "./src/index.ts"
+
},
+
"./blob": {
+
"types": "./src/blob.ts",
+
"default": "./src/blob.ts"
+
},
+
"./compression": {
+
"types": "./src/compression.ts",
+
"default": "./src/compression.ts"
+
},
+
"./subfs": {
+
"types": "./src/subfs.ts",
+
"default": "./src/subfs.ts"
+
}
+
},
+
"dependencies": {
+
"@atproto/api": "^0.14.1",
+
"@wisp/lexicons": "workspace:*",
+
"multiformats": "^13.3.1"
+
}
+
}
+108
packages/@wisp/atproto-utils/src/blob.ts
···
···
+
import type { BlobRef } from "@atproto/lexicon";
+
import type { Directory, File } from "@wisp/lexicons/types/place/wisp/fs";
+
import { CID } from 'multiformats/cid';
+
import { sha256 } from 'multiformats/hashes/sha2';
+
import * as raw from 'multiformats/codecs/raw';
+
import { createHash } from 'crypto';
+
import * as mf from 'multiformats';
+
+
/**
+
* Compute CID (Content Identifier) for blob content
+
* Uses the same algorithm as AT Protocol: CIDv1 with raw codec and SHA-256
+
* Based on @atproto/common/src/ipld.ts sha256RawToCid implementation
+
*/
+
export function computeCID(content: Buffer): string {
+
// Use node crypto to compute sha256 hash (same as AT Protocol)
+
const hash = createHash('sha256').update(content).digest();
+
// Create digest object from hash bytes
+
const digest = mf.digest.create(sha256.code, hash);
+
// Create CIDv1 with raw codec
+
const cid = CID.createV1(raw.code, digest);
+
return cid.toString();
+
}
+
+
/**
+
* Extract blob information from a directory tree
+
* Returns a map of file paths to their blob refs and CIDs
+
*/
+
export function extractBlobMap(
+
directory: Directory,
+
currentPath: string = ''
+
): Map<string, { blobRef: BlobRef; cid: string }> {
+
const blobMap = new Map<string, { blobRef: BlobRef; cid: string }>();
+
+
for (const entry of directory.entries) {
+
const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
+
+
if ('type' in entry.node && entry.node.type === 'file') {
+
const fileNode = entry.node as File;
+
// AT Protocol SDK returns BlobRef class instances, not plain objects
+
// The ref is a CID instance that can be converted to string
+
if (fileNode.blob && fileNode.blob.ref) {
+
const cidString = fileNode.blob.ref.toString();
+
blobMap.set(fullPath, {
+
blobRef: fileNode.blob,
+
cid: cidString
+
});
+
}
+
} else if ('type' in entry.node && entry.node.type === 'directory') {
+
const subMap = extractBlobMap(entry.node as Directory, fullPath);
+
subMap.forEach((value, key) => blobMap.set(key, value));
+
}
+
// Skip subfs nodes - they don't contain blobs in the main tree
+
}
+
+
return blobMap;
+
}
+
+
interface IpldLink {
+
$link: string;
+
}
+
+
interface TypedBlobRef {
+
ref: CID | IpldLink;
+
}
+
+
interface UntypedBlobRef {
+
cid: string;
+
}
+
+
function isIpldLink(obj: unknown): obj is IpldLink {
+
return typeof obj === 'object' && obj !== null && '$link' in obj && typeof (obj as IpldLink).$link === 'string';
+
}
+
+
function isTypedBlobRef(obj: unknown): obj is TypedBlobRef {
+
return typeof obj === 'object' && obj !== null && 'ref' in obj;
+
}
+
+
function isUntypedBlobRef(obj: unknown): obj is UntypedBlobRef {
+
return typeof obj === 'object' && obj !== null && 'cid' in obj && typeof (obj as UntypedBlobRef).cid === 'string';
+
}
+
+
/**
+
* Extract CID from a blob reference (handles multiple blob ref formats)
+
*/
+
export function extractBlobCid(blobRef: unknown): string | null {
+
if (isIpldLink(blobRef)) {
+
return blobRef.$link;
+
}
+
+
if (isTypedBlobRef(blobRef)) {
+
const ref = blobRef.ref;
+
+
const cid = CID.asCID(ref);
+
if (cid) {
+
return cid.toString();
+
}
+
+
if (isIpldLink(ref)) {
+
return ref.$link;
+
}
+
}
+
+
if (isUntypedBlobRef(blobRef)) {
+
return blobRef.cid;
+
}
+
+
return null;
+
}
+95
packages/@wisp/atproto-utils/src/compression.ts
···
···
+
import { gzipSync } from 'zlib';
+
+
/**
+
* Determine if a file should be gzip compressed based on its MIME type and filename
+
*/
+
export function shouldCompressFile(mimeType: string, fileName?: string): boolean {
+
// Never compress _redirects file - it needs to be plain text for the hosting service
+
if (fileName && (fileName.endsWith('/_redirects') || fileName === '_redirects')) {
+
return false;
+
}
+
+
// Compress text-based files and uncompressed audio formats
+
const compressibleTypes = [
+
'text/html',
+
'text/css',
+
'text/javascript',
+
'application/javascript',
+
'application/json',
+
'image/svg+xml',
+
'text/xml',
+
'application/xml',
+
'text/plain',
+
'application/x-javascript',
+
// Uncompressed audio formats (WAV, AIFF, etc.)
+
'audio/wav',
+
'audio/wave',
+
'audio/x-wav',
+
'audio/aiff',
+
'audio/x-aiff'
+
];
+
+
// Check if mime type starts with any compressible type
+
return compressibleTypes.some(type => mimeType.startsWith(type));
+
}
+
+
/**
+
* Determines if a MIME type should benefit from gzip compression.
+
* Returns true for text-based web assets (HTML, CSS, JS, JSON, XML, SVG).
+
* Returns false for already-compressed formats (images, video, audio, PDFs).
+
*/
+
export function shouldCompressMimeType(mimeType: string | undefined): boolean {
+
if (!mimeType) return false;
+
+
const mime = mimeType.toLowerCase();
+
+
// Text-based web assets and uncompressed audio that benefit from compression
+
const compressibleTypes = [
+
'text/html',
+
'text/css',
+
'text/javascript',
+
'application/javascript',
+
'application/x-javascript',
+
'text/xml',
+
'application/xml',
+
'application/json',
+
'text/plain',
+
'image/svg+xml',
+
// Uncompressed audio formats
+
'audio/wav',
+
'audio/wave',
+
'audio/x-wav',
+
'audio/aiff',
+
'audio/x-aiff',
+
];
+
+
if (compressibleTypes.some(type => mime === type || mime.startsWith(type))) {
+
return true;
+
}
+
+
// Already-compressed formats that should NOT be double-compressed
+
const alreadyCompressedPrefixes = [
+
'video/',
+
'audio/',
+
'image/',
+
'application/pdf',
+
'application/zip',
+
'application/gzip',
+
];
+
+
if (alreadyCompressedPrefixes.some(prefix => mime.startsWith(prefix))) {
+
return false;
+
}
+
+
// Default to not compressing for unknown types
+
return false;
+
}
+
+
/**
+
* Compress a file using gzip with deterministic output
+
*/
+
export function compressFile(content: Buffer): Buffer {
+
return gzipSync(content, {
+
level: 9
+
});
+
}
+8
packages/@wisp/atproto-utils/src/index.ts
···
···
+
// Blob utilities
+
export { computeCID, extractBlobMap, extractBlobCid } from './blob';
+
+
// Compression utilities
+
export { shouldCompressFile, shouldCompressMimeType, compressFile } from './compression';
+
+
// Subfs utilities
+
export { extractSubfsUris } from './subfs';
+31
packages/@wisp/atproto-utils/src/subfs.ts
···
···
+
import type { Directory } from "@wisp/lexicons/types/place/wisp/fs";
+
+
/**
+
* Extract all subfs URIs from a directory tree with their mount paths
+
*/
+
export function extractSubfsUris(
+
directory: Directory,
+
currentPath: string = ''
+
): Array<{ uri: string; path: string }> {
+
const uris: Array<{ uri: string; path: string }> = [];
+
+
for (const entry of directory.entries) {
+
const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
+
+
if ('type' in entry.node) {
+
if (entry.node.type === 'subfs') {
+
// Subfs node with subject URI
+
const subfsNode = entry.node as any;
+
if (subfsNode.subject) {
+
uris.push({ uri: subfsNode.subject, path: fullPath });
+
}
+
} else if (entry.node.type === 'directory') {
+
// Recursively search subdirectories
+
const subUris = extractSubfsUris(entry.node as Directory, fullPath);
+
uris.push(...subUris);
+
}
+
}
+
}
+
+
return uris;
+
}
+9
packages/@wisp/atproto-utils/tsconfig.json
···
···
+
{
+
"extends": "../../../tsconfig.json",
+
"compilerOptions": {
+
"outDir": "./dist",
+
"rootDir": "./src"
+
},
+
"include": ["src/**/*"],
+
"exclude": ["node_modules", "dist"]
+
}
+14
packages/@wisp/constants/package.json
···
···
+
{
+
"name": "@wisp/constants",
+
"version": "1.0.0",
+
"private": true,
+
"type": "module",
+
"main": "./src/index.ts",
+
"types": "./src/index.ts",
+
"exports": {
+
".": {
+
"types": "./src/index.ts",
+
"default": "./src/index.ts"
+
}
+
}
+
}
+32
packages/@wisp/constants/src/index.ts
···
···
+
/**
+
* Shared constants for wisp.place
+
*/
+
+
// Domain configuration
+
export const getBaseHost = () => {
+
if (typeof Bun !== 'undefined') {
+
return Bun.env.BASE_DOMAIN || "wisp.place";
+
}
+
return process.env.BASE_DOMAIN || "wisp.place";
+
};
+
+
export const BASE_HOST = getBaseHost();
+
+
// File size limits
+
export const MAX_SITE_SIZE = 300 * 1024 * 1024; // 300MB
+
export const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
+
export const MAX_FILE_COUNT = 1000;
+
+
// Cache configuration
+
export const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
+
+
// Fetch timeouts and limits
+
export const FETCH_TIMEOUT_MS = 30000; // 30 seconds
+
export const MAX_JSON_SIZE = 10 * 1024 * 1024; // 10MB
+
export const MAX_BLOB_SIZE = MAX_FILE_SIZE; // Use file size limit
+
+
// Directory limits (AT Protocol lexicon constraints)
+
export const MAX_ENTRIES_PER_DIRECTORY = 500;
+
+
// Compression settings
+
export const GZIP_COMPRESSION_LEVEL = 9;
+9
packages/@wisp/constants/tsconfig.json
···
···
+
{
+
"extends": "../../../tsconfig.json",
+
"compilerOptions": {
+
"outDir": "./dist",
+
"rootDir": "./src"
+
},
+
"include": ["src/**/*"],
+
"exclude": ["node_modules", "dist"]
+
}
+29
packages/@wisp/database/package.json
···
···
+
{
+
"name": "@wisp/database",
+
"version": "1.0.0",
+
"private": true,
+
"type": "module",
+
"main": "./src/index.ts",
+
"types": "./src/index.ts",
+
"exports": {
+
".": {
+
"types": "./src/index.ts",
+
"default": "./src/index.ts"
+
},
+
"./types": {
+
"types": "./src/types.ts",
+
"default": "./src/types.ts"
+
}
+
},
+
"dependencies": {
+
"postgres": "^3.4.5"
+
},
+
"peerDependencies": {
+
"bun": "^1.0.0"
+
},
+
"peerDependenciesMeta": {
+
"bun": {
+
"optional": true
+
}
+
}
+
}
+22
packages/@wisp/database/src/index.ts
···
···
+
/**
+
* Shared database utilities for wisp.place
+
*
+
* This package provides database query functions that work across both
+
* main-app (Bun SQL) and hosting-service (postgres) environments.
+
*
+
* The actual database client is passed in by the consuming application.
+
*/
+
+
export * from './types';
+
+
// Re-export types
+
export type {
+
DomainLookup,
+
CustomDomainLookup,
+
SiteRecord,
+
OAuthState,
+
OAuthSession,
+
OAuthKey,
+
CookieSecret,
+
AdminUser
+
} from './types';
+56
packages/@wisp/database/src/types.ts
···
···
+
/**
+
* Shared database types used across main-app and hosting-service
+
*/
+
+
export interface DomainLookup {
+
did: string;
+
rkey: string | null;
+
}
+
+
export interface CustomDomainLookup {
+
id: string;
+
domain: string;
+
did: string;
+
rkey: string | null;
+
verified: boolean;
+
}
+
+
export interface SiteRecord {
+
did: string;
+
rkey: string;
+
display_name?: string;
+
created_at?: number;
+
updated_at?: number;
+
}
+
+
export interface OAuthState {
+
key: string;
+
data: string;
+
created_at?: number;
+
expires_at?: number;
+
}
+
+
export interface OAuthSession {
+
sub: string;
+
data: string;
+
updated_at?: number;
+
expires_at?: number;
+
}
+
+
export interface OAuthKey {
+
kid: string;
+
jwk: string;
+
created_at?: number;
+
}
+
+
export interface CookieSecret {
+
id: string;
+
secret: string;
+
created_at?: number;
+
}
+
+
export interface AdminUser {
+
username: string;
+
password_hash: string;
+
created_at?: number;
+
}
+9
packages/@wisp/database/tsconfig.json
···
···
+
{
+
"extends": "../../../tsconfig.json",
+
"compilerOptions": {
+
"outDir": "./dist",
+
"rootDir": "./src"
+
},
+
"include": ["src/**/*"],
+
"exclude": ["node_modules", "dist"]
+
}
+34
packages/@wisp/fs-utils/package.json
···
···
+
{
+
"name": "@wisp/fs-utils",
+
"version": "1.0.0",
+
"private": true,
+
"type": "module",
+
"main": "./src/index.ts",
+
"types": "./src/index.ts",
+
"exports": {
+
".": {
+
"types": "./src/index.ts",
+
"default": "./src/index.ts"
+
},
+
"./path": {
+
"types": "./src/path.ts",
+
"default": "./src/path.ts"
+
},
+
"./tree": {
+
"types": "./src/tree.ts",
+
"default": "./src/tree.ts"
+
},
+
"./manifest": {
+
"types": "./src/manifest.ts",
+
"default": "./src/manifest.ts"
+
},
+
"./subfs-split": {
+
"types": "./src/subfs-split.ts",
+
"default": "./src/subfs-split.ts"
+
}
+
},
+
"dependencies": {
+
"@atproto/api": "^0.14.1",
+
"@wisp/lexicons": "workspace:*"
+
}
+
}
+12
packages/@wisp/fs-utils/src/index.ts
···
···
+
// Path utilities
+
export { sanitizePath, normalizePath } from './path';
+
+
// Tree processing
+
export type { UploadedFile, FileUploadResult, ProcessedDirectory } from './tree';
+
export { processUploadedFiles, updateFileBlobs, countFilesInDirectory, collectFileCidsFromEntries } from './tree';
+
+
// Manifest creation
+
export { createManifest } from './manifest';
+
+
// Subfs splitting utilities
+
export { estimateDirectorySize, findLargeDirectories, replaceDirectoryWithSubfs } from './subfs-split';
+27
packages/@wisp/fs-utils/src/manifest.ts
···
···
+
import type { Record, Directory } from "@wisp/lexicons/types/place/wisp/fs";
+
import { validateRecord } from "@wisp/lexicons/types/place/wisp/fs";
+
+
/**
+
* Create the manifest record for a site
+
*/
+
export function createManifest(
+
siteName: string,
+
root: Directory,
+
fileCount: number
+
): Record {
+
const manifest = {
+
$type: 'place.wisp.fs' as const,
+
site: siteName,
+
root,
+
fileCount,
+
createdAt: new Date().toISOString()
+
};
+
+
// Validate the manifest before returning
+
const validationResult = validateRecord(manifest);
+
if (!validationResult.success) {
+
throw new Error(`Invalid manifest: ${validationResult.error?.message || 'Validation failed'}`);
+
}
+
+
return manifest;
+
}
+29
packages/@wisp/fs-utils/src/path.ts
···
···
+
/**
+
* Sanitize a file path to prevent directory traversal attacks
+
* Removes any path segments that attempt to go up directories
+
*/
+
export function sanitizePath(filePath: string): string {
+
// Remove leading slashes
+
let cleaned = filePath.replace(/^\/+/, '');
+
+
// Split into segments and filter out dangerous ones
+
const segments = cleaned.split('/').filter(segment => {
+
// Remove empty segments
+
if (!segment || segment === '.') return false;
+
// Remove parent directory references
+
if (segment === '..') return false;
+
// Remove segments with null bytes
+
if (segment.includes('\0')) return false;
+
return true;
+
});
+
+
// Rejoin the safe segments
+
return segments.join('/');
+
}
+
+
/**
+
* Normalize a path by removing leading base folder names
+
*/
+
export function normalizePath(path: string): string {
+
return path.replace(/^[^\/]*\//, '');
+
}
+113
packages/@wisp/fs-utils/src/subfs-split.ts
···
···
+
import type { Directory } from "@wisp/lexicons/types/place/wisp/fs";
+
+
/**
+
* Estimate the JSON size of a directory tree
+
*/
+
export function estimateDirectorySize(directory: Directory): number {
+
return JSON.stringify(directory).length;
+
}
+
+
/**
+
* Count files in a directory tree
+
*/
+
export function countFilesInDirectory(directory: Directory): number {
+
let count = 0;
+
for (const entry of directory.entries) {
+
if ('type' in entry.node && entry.node.type === 'file') {
+
count++;
+
} else if ('type' in entry.node && entry.node.type === 'directory') {
+
count += countFilesInDirectory(entry.node as Directory);
+
}
+
}
+
return count;
+
}
+
+
/**
+
* Find all directories in a tree with their paths and sizes
+
*/
+
export function findLargeDirectories(directory: Directory, currentPath: string = ''): Array<{
+
path: string;
+
directory: Directory;
+
size: number;
+
fileCount: number;
+
}> {
+
const result: Array<{ path: string; directory: Directory; size: number; fileCount: number }> = [];
+
+
for (const entry of directory.entries) {
+
if ('type' in entry.node && entry.node.type === 'directory') {
+
const dirPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
+
const dir = entry.node as Directory;
+
const size = estimateDirectorySize(dir);
+
const fileCount = countFilesInDirectory(dir);
+
+
result.push({ path: dirPath, directory: dir, size, fileCount });
+
+
// Recursively find subdirectories
+
const subdirs = findLargeDirectories(dir, dirPath);
+
result.push(...subdirs);
+
}
+
}
+
+
return result;
+
}
+
+
/**
+
* Replace a directory with a subfs node in the tree
+
*/
+
export function replaceDirectoryWithSubfs(
+
directory: Directory,
+
targetPath: string,
+
subfsUri: string
+
): Directory {
+
const pathParts = targetPath.split('/');
+
const targetName = pathParts[pathParts.length - 1];
+
const parentPath = pathParts.slice(0, -1).join('/');
+
+
// If this is a root-level directory
+
if (pathParts.length === 1) {
+
const newEntries = directory.entries.map(entry => {
+
if (entry.name === targetName && 'type' in entry.node && entry.node.type === 'directory') {
+
return {
+
name: entry.name,
+
node: {
+
$type: 'place.wisp.fs#subfs' as const,
+
type: 'subfs' as const,
+
subject: subfsUri,
+
flat: false // Preserve directory structure
+
}
+
};
+
}
+
return entry;
+
});
+
+
return {
+
$type: 'place.wisp.fs#directory' as const,
+
type: 'directory' as const,
+
entries: newEntries
+
};
+
}
+
+
// Recursively navigate to parent directory
+
const newEntries = directory.entries.map(entry => {
+
if ('type' in entry.node && entry.node.type === 'directory') {
+
const entryPath = entry.name;
+
if (parentPath.startsWith(entryPath) || parentPath === entry.name) {
+
const remainingPath = pathParts.slice(1).join('/');
+
return {
+
name: entry.name,
+
node: {
+
...replaceDirectoryWithSubfs(entry.node as Directory, remainingPath, subfsUri),
+
$type: 'place.wisp.fs#directory' as const
+
}
+
};
+
}
+
}
+
return entry;
+
});
+
+
return {
+
$type: 'place.wisp.fs#directory' as const,
+
type: 'directory' as const,
+
entries: newEntries
+
};
+
}
+241
packages/@wisp/fs-utils/src/tree.ts
···
···
+
import type { BlobRef } from "@atproto/api";
+
import type { Directory, Entry, File } from "@wisp/lexicons/types/place/wisp/fs";
+
+
export interface UploadedFile {
+
name: string;
+
content: Buffer;
+
mimeType: string;
+
size: number;
+
compressed?: boolean;
+
base64Encoded?: boolean;
+
originalMimeType?: string;
+
}
+
+
export interface FileUploadResult {
+
hash: string;
+
blobRef: BlobRef;
+
encoding?: 'gzip';
+
mimeType?: string;
+
base64?: boolean;
+
}
+
+
export interface ProcessedDirectory {
+
directory: Directory;
+
fileCount: number;
+
}
+
+
/**
+
* Process uploaded files into a directory structure
+
*/
+
export function processUploadedFiles(files: UploadedFile[]): ProcessedDirectory {
+
const entries: Entry[] = [];
+
let fileCount = 0;
+
+
// Group files by directory
+
const directoryMap = new Map<string, UploadedFile[]>();
+
+
for (const file of files) {
+
// Skip undefined/null files (defensive)
+
if (!file || !file.name) {
+
console.error('Skipping undefined or invalid file in processUploadedFiles');
+
continue;
+
}
+
+
// Remove any base folder name from the path
+
const normalizedPath = file.name.replace(/^[^\/]*\//, '');
+
+
// Skip files in .git directories
+
if (normalizedPath.startsWith('.git/') || normalizedPath === '.git') {
+
continue;
+
}
+
+
const parts = normalizedPath.split('/');
+
+
if (parts.length === 1) {
+
// Root level file
+
entries.push({
+
name: parts[0],
+
node: {
+
$type: 'place.wisp.fs#file' as const,
+
type: 'file' as const,
+
blob: undefined as any // Will be filled after upload
+
}
+
});
+
fileCount++;
+
} else {
+
// File in subdirectory
+
const dirPath = parts.slice(0, -1).join('/');
+
if (!directoryMap.has(dirPath)) {
+
directoryMap.set(dirPath, []);
+
}
+
directoryMap.get(dirPath)!.push({
+
...file,
+
name: normalizedPath
+
});
+
}
+
}
+
+
// Process subdirectories
+
for (const [dirPath, dirFiles] of directoryMap) {
+
const dirEntries: Entry[] = [];
+
+
for (const file of dirFiles) {
+
const fileName = file.name.split('/').pop()!;
+
dirEntries.push({
+
name: fileName,
+
node: {
+
$type: 'place.wisp.fs#file' as const,
+
type: 'file' as const,
+
blob: undefined as any // Will be filled after upload
+
}
+
});
+
fileCount++;
+
}
+
+
// Build nested directory structure
+
const pathParts = dirPath.split('/');
+
let currentEntries = entries;
+
+
for (let i = 0; i < pathParts.length; i++) {
+
const part = pathParts[i];
+
const isLast = i === pathParts.length - 1;
+
+
let existingEntry = currentEntries.find(e => e.name === part);
+
+
if (!existingEntry) {
+
const newDir = {
+
$type: 'place.wisp.fs#directory' as const,
+
type: 'directory' as const,
+
entries: isLast ? dirEntries : []
+
};
+
+
existingEntry = {
+
name: part,
+
node: newDir
+
};
+
currentEntries.push(existingEntry);
+
} else if ('entries' in existingEntry.node && isLast) {
+
(existingEntry.node as any).entries.push(...dirEntries);
+
}
+
+
if (existingEntry && 'entries' in existingEntry.node) {
+
currentEntries = (existingEntry.node as any).entries;
+
}
+
}
+
}
+
+
const result = {
+
directory: {
+
$type: 'place.wisp.fs#directory' as const,
+
type: 'directory' as const,
+
entries
+
},
+
fileCount
+
};
+
+
return result;
+
}
+
+
/**
+
* Update file blobs in directory structure after upload
+
* Uses path-based matching to correctly match files in nested directories
+
* Filters out files that were not successfully uploaded
+
*/
+
export function updateFileBlobs(
+
directory: Directory,
+
uploadResults: FileUploadResult[],
+
filePaths: string[],
+
currentPath: string = '',
+
successfulPaths?: Set<string>
+
): Directory {
+
const updatedEntries = directory.entries.map(entry => {
+
if ('type' in entry.node && entry.node.type === 'file') {
+
// Build the full path for this file
+
const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
+
+
// If successfulPaths is provided, skip files that weren't successfully uploaded
+
if (successfulPaths && !successfulPaths.has(fullPath)) {
+
return null; // Filter out failed files
+
}
+
+
// Find exact match in filePaths (need to handle normalized paths)
+
const fileIndex = filePaths.findIndex((path) => {
+
// Normalize both paths by removing leading base folder
+
const normalizedUploadPath = path.replace(/^[^\/]*\//, '');
+
const normalizedEntryPath = fullPath;
+
return normalizedUploadPath === normalizedEntryPath || path === fullPath;
+
});
+
+
if (fileIndex !== -1 && uploadResults[fileIndex]) {
+
const result = uploadResults[fileIndex];
+
const blobRef = result.blobRef;
+
+
return {
+
...entry,
+
node: {
+
$type: 'place.wisp.fs#file' as const,
+
type: 'file' as const,
+
blob: blobRef,
+
...(result.encoding && { encoding: result.encoding }),
+
...(result.mimeType && { mimeType: result.mimeType }),
+
...(result.base64 && { base64: result.base64 })
+
}
+
};
+
} else {
+
console.error(`Could not find blob for file: ${fullPath}`);
+
return null; // Filter out files without blobs
+
}
+
} else if ('type' in entry.node && entry.node.type === 'directory') {
+
const dirPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
+
return {
+
...entry,
+
node: updateFileBlobs(entry.node as Directory, uploadResults, filePaths, dirPath, successfulPaths)
+
};
+
}
+
return entry;
+
}).filter(entry => entry !== null) as Entry[]; // Remove null entries (failed files)
+
+
const result = {
+
$type: 'place.wisp.fs#directory' as const,
+
type: 'directory' as const,
+
entries: updatedEntries
+
};
+
+
return result;
+
}
+
+
/**
+
* Count files in a directory tree
+
*/
+
export function countFilesInDirectory(directory: Directory): number {
+
let count = 0;
+
for (const entry of directory.entries) {
+
if ('type' in entry.node && entry.node.type === 'file') {
+
count++;
+
} else if ('type' in entry.node && entry.node.type === 'directory') {
+
count += countFilesInDirectory(entry.node as Directory);
+
}
+
}
+
return count;
+
}
+
+
/**
+
* Recursively collect file CIDs from entries for incremental update tracking
+
*/
+
export function collectFileCidsFromEntries(entries: Entry[], pathPrefix: string, fileCids: Record<string, string>): void {
+
for (const entry of entries) {
+
const currentPath = pathPrefix ? `${pathPrefix}/${entry.name}` : entry.name;
+
const node = entry.node;
+
+
if ('type' in node && node.type === 'directory' && 'entries' in node) {
+
collectFileCidsFromEntries(node.entries, currentPath, fileCids);
+
} else if ('type' in node && node.type === 'file' && 'blob' in node) {
+
const fileNode = node as File;
+
// Extract CID from blob ref
+
if (fileNode.blob && fileNode.blob.ref) {
+
const cid = fileNode.blob.ref.toString();
+
fileCids[currentPath] = cid;
+
}
+
}
+
}
+
}
+9
packages/@wisp/fs-utils/tsconfig.json
···
···
+
{
+
"extends": "../../../tsconfig.json",
+
"compilerOptions": {
+
"outDir": "./dist",
+
"rootDir": "./src"
+
},
+
"include": ["src/**/*"],
+
"exclude": ["node_modules", "dist"]
+
}
+25
packages/@wisp/lexicons/README.md
···
···
+
# @wisp/lexicons
+
+
Shared AT Protocol lexicon definitions and generated TypeScript types for the wisp.place project.
+
+
## Contents
+
+
- `/lexicons` - Source lexicon JSON definitions
+
- `/src` - Generated TypeScript types and validation functions
+
+
## Usage
+
+
```typescript
+
import { ids, lexicons } from '@wisp/lexicons';
+
import type { PlaceWispFs } from '@wisp/lexicons/types/place/wisp/fs';
+
```
+
+
## Code Generation
+
+
To regenerate types from lexicon definitions:
+
+
```bash
+
npm run codegen
+
```
+
+
This uses `@atproto/lex-cli` to generate TypeScript types from the JSON schemas in `/lexicons`.
+44
packages/@wisp/lexicons/package.json
···
···
+
{
+
"name": "@wisp/lexicons",
+
"version": "1.0.0",
+
"private": true,
+
"type": "module",
+
"main": "./src/index.ts",
+
"types": "./src/index.ts",
+
"exports": {
+
".": {
+
"types": "./src/index.ts",
+
"default": "./src/index.ts"
+
},
+
"./types/place/wisp/fs": {
+
"types": "./src/types/place/wisp/fs.ts",
+
"default": "./src/types/place/wisp/fs.ts"
+
},
+
"./types/place/wisp/settings": {
+
"types": "./src/types/place/wisp/settings.ts",
+
"default": "./src/types/place/wisp/settings.ts"
+
},
+
"./types/place/wisp/subfs": {
+
"types": "./src/types/place/wisp/subfs.ts",
+
"default": "./src/types/place/wisp/subfs.ts"
+
},
+
"./lexicons": {
+
"types": "./src/lexicons.ts",
+
"default": "./src/lexicons.ts"
+
},
+
"./util": {
+
"types": "./src/util.ts",
+
"default": "./src/util.ts"
+
}
+
},
+
"scripts": {
+
"codegen": "lex gen-server ./src ./lexicons"
+
},
+
"dependencies": {
+
"@atproto/lexicon": "^0.5.1",
+
"@atproto/xrpc-server": "^0.9.5"
+
},
+
"devDependencies": {
+
"@atproto/lex-cli": "^0.9.5"
+
}
+
}
+11
packages/@wisp/lexicons/tsconfig.json
···
···
+
{
+
"extends": "../../../tsconfig.json",
+
"compilerOptions": {
+
"outDir": "./dist",
+
"rootDir": "./src",
+
"declaration": true,
+
"declarationMap": true
+
},
+
"include": ["src/**/*"],
+
"exclude": ["node_modules", "dist"]
+
}
+34
packages/@wisp/observability/package.json
···
···
+
{
+
"name": "@wisp/observability",
+
"version": "1.0.0",
+
"private": true,
+
"type": "module",
+
"main": "./src/index.ts",
+
"types": "./src/index.ts",
+
"exports": {
+
".": {
+
"types": "./src/index.ts",
+
"default": "./src/index.ts"
+
},
+
"./core": {
+
"types": "./src/core.ts",
+
"default": "./src/core.ts"
+
},
+
"./middleware/elysia": {
+
"types": "./src/middleware/elysia.ts",
+
"default": "./src/middleware/elysia.ts"
+
},
+
"./middleware/hono": {
+
"types": "./src/middleware/hono.ts",
+
"default": "./src/middleware/hono.ts"
+
}
+
},
+
"peerDependencies": {
+
"hono": "^4.0.0"
+
},
+
"peerDependenciesMeta": {
+
"hono": {
+
"optional": true
+
}
+
}
+
}
+11
packages/@wisp/observability/src/index.ts
···
···
+
/**
+
* @wisp/observability
+
* Framework-agnostic observability package with Elysia and Hono middleware
+
*/
+
+
// Export everything from core
+
export * from './core'
+
+
// Note: Middleware should be imported from specific subpaths:
+
// - import { observabilityMiddleware } from '@wisp/observability/middleware/elysia'
+
// - import { observabilityMiddleware, observabilityErrorHandler } from '@wisp/observability/middleware/hono'
+49
packages/@wisp/observability/src/middleware/elysia.ts
···
···
+
import { metricsCollector, logCollector } from '../core'
+
+
/**
+
* Elysia middleware for observability
+
* Tracks request metrics and logs errors
+
*/
+
export function observabilityMiddleware(service: string) {
+
return {
+
beforeHandle: ({ request }: any) => {
+
// Store start time on request object
+
(request as any).__startTime = Date.now()
+
},
+
afterHandle: ({ request, set }: any) => {
+
const duration = Date.now() - ((request as any).__startTime || Date.now())
+
const url = new URL(request.url)
+
+
metricsCollector.recordRequest(
+
url.pathname,
+
request.method,
+
set.status || 200,
+
duration,
+
service
+
)
+
},
+
onError: ({ request, error, set }: any) => {
+
const duration = Date.now() - ((request as any).__startTime || Date.now())
+
const url = new URL(request.url)
+
+
metricsCollector.recordRequest(
+
url.pathname,
+
request.method,
+
set.status || 500,
+
duration,
+
service
+
)
+
+
// Don't log 404 errors
+
const statusCode = set.status || 500
+
if (statusCode !== 404) {
+
logCollector.error(
+
`Request failed: ${request.method} ${url.pathname}`,
+
service,
+
error,
+
{ statusCode }
+
)
+
}
+
}
+
}
+
}
+44
packages/@wisp/observability/src/middleware/hono.ts
···
···
+
import type { Context } from 'hono'
+
import { metricsCollector, logCollector } from '../core'
+
+
/**
+
* Hono middleware for observability
+
* Tracks request metrics
+
*/
+
export function observabilityMiddleware(service: string) {
+
return async (c: Context, next: () => Promise<void>) => {
+
const startTime = Date.now()
+
+
await next()
+
+
const duration = Date.now() - startTime
+
const { pathname } = new URL(c.req.url)
+
+
metricsCollector.recordRequest(
+
pathname,
+
c.req.method,
+
c.res.status,
+
duration,
+
service
+
)
+
}
+
}
+
+
/**
+
* Hono error handler for observability
+
* Logs errors with context
+
*/
+
export function observabilityErrorHandler(service: string) {
+
return (err: Error, c: Context) => {
+
const { pathname } = new URL(c.req.url)
+
+
logCollector.error(
+
`Request failed: ${c.req.method} ${pathname}`,
+
service,
+
err,
+
{ statusCode: c.res.status || 500 }
+
)
+
+
return c.text('Internal Server Error', 500)
+
}
+
}
+9
packages/@wisp/observability/tsconfig.json
···
···
+
{
+
"extends": "../../../tsconfig.json",
+
"compilerOptions": {
+
"outDir": "./dist",
+
"rootDir": "./src"
+
},
+
"include": ["src/**/*"],
+
"exclude": ["node_modules", "dist"]
+
}
+14
packages/@wisp/safe-fetch/package.json
···
···
+
{
+
"name": "@wisp/safe-fetch",
+
"version": "1.0.0",
+
"private": true,
+
"type": "module",
+
"main": "./src/index.ts",
+
"types": "./src/index.ts",
+
"exports": {
+
".": {
+
"types": "./src/index.ts",
+
"default": "./src/index.ts"
+
}
+
}
+
}
+187
packages/@wisp/safe-fetch/src/index.ts
···
···
+
/**
+
* SSRF-hardened fetch utility
+
* Prevents requests to private networks, localhost, and enforces timeouts/size limits
+
*/
+
+
const BLOCKED_IP_RANGES = [
+
/^127\./, // 127.0.0.0/8 - Loopback
+
/^10\./, // 10.0.0.0/8 - Private
+
/^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12 - Private
+
/^192\.168\./, // 192.168.0.0/16 - Private
+
/^169\.254\./, // 169.254.0.0/16 - Link-local
+
/^::1$/, // IPv6 loopback
+
/^fe80:/, // IPv6 link-local
+
/^fc00:/, // IPv6 unique local
+
/^fd00:/, // IPv6 unique local
+
];
+
+
const BLOCKED_HOSTS = [
+
'localhost',
+
'metadata.google.internal',
+
'169.254.169.254',
+
];
+
+
const FETCH_TIMEOUT = 120000; // 120 seconds
+
const FETCH_TIMEOUT_BLOB = 120000; // 2 minutes for blob downloads
+
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
+
const MAX_JSON_SIZE = 1024 * 1024; // 1MB
+
const MAX_BLOB_SIZE = 500 * 1024 * 1024; // 500MB
+
const MAX_REDIRECTS = 10;
+
+
function isBlockedHost(hostname: string): boolean {
+
const lowerHost = hostname.toLowerCase();
+
+
if (BLOCKED_HOSTS.includes(lowerHost)) {
+
return true;
+
}
+
+
for (const pattern of BLOCKED_IP_RANGES) {
+
if (pattern.test(lowerHost)) {
+
return true;
+
}
+
}
+
+
return false;
+
}
+
+
export async function safeFetch(
+
url: string,
+
options?: RequestInit & { maxSize?: number; timeout?: number }
+
): Promise<Response> {
+
const timeoutMs = options?.timeout ?? FETCH_TIMEOUT;
+
const maxSize = options?.maxSize ?? MAX_RESPONSE_SIZE;
+
+
// Parse and validate URL
+
let parsedUrl: URL;
+
try {
+
parsedUrl = new URL(url);
+
} catch (err) {
+
throw new Error(`Invalid URL: ${url}`);
+
}
+
+
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
+
throw new Error(`Blocked protocol: ${parsedUrl.protocol}`);
+
}
+
+
const hostname = parsedUrl.hostname;
+
if (isBlockedHost(hostname)) {
+
throw new Error(`Blocked host: ${hostname}`);
+
}
+
+
const controller = new AbortController();
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
+
+
try {
+
const response = await fetch(url, {
+
...options,
+
signal: controller.signal,
+
redirect: 'follow',
+
});
+
+
const contentLength = response.headers.get('content-length');
+
if (contentLength && parseInt(contentLength, 10) > maxSize) {
+
throw new Error(`Response too large: ${contentLength} bytes`);
+
}
+
+
return response;
+
} catch (err) {
+
if (err instanceof Error && err.name === 'AbortError') {
+
throw new Error(`Request timeout after ${timeoutMs}ms`);
+
}
+
throw err;
+
} finally {
+
clearTimeout(timeoutId);
+
}
+
}
+
+
export async function safeFetchJson<T = any>(
+
url: string,
+
options?: RequestInit & { maxSize?: number; timeout?: number }
+
): Promise<T> {
+
const maxJsonSize = options?.maxSize ?? MAX_JSON_SIZE;
+
const response = await safeFetch(url, { ...options, maxSize: maxJsonSize });
+
+
if (!response.ok) {
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+
}
+
+
const reader = response.body?.getReader();
+
if (!reader) {
+
throw new Error('No response body');
+
}
+
+
const chunks: Uint8Array[] = [];
+
let totalSize = 0;
+
+
try {
+
while (true) {
+
const { done, value } = await reader.read();
+
if (done) break;
+
+
totalSize += value.length;
+
if (totalSize > maxJsonSize) {
+
throw new Error(`Response exceeds max size: ${maxJsonSize} bytes`);
+
}
+
+
chunks.push(value);
+
}
+
} finally {
+
reader.releaseLock();
+
}
+
+
const combined = new Uint8Array(totalSize);
+
let offset = 0;
+
for (const chunk of chunks) {
+
combined.set(chunk, offset);
+
offset += chunk.length;
+
}
+
+
const text = new TextDecoder().decode(combined);
+
return JSON.parse(text);
+
}
+
+
export async function safeFetchBlob(
+
url: string,
+
options?: RequestInit & { maxSize?: number; timeout?: number }
+
): Promise<Uint8Array> {
+
const maxBlobSize = options?.maxSize ?? MAX_BLOB_SIZE;
+
const timeoutMs = options?.timeout ?? FETCH_TIMEOUT_BLOB;
+
const response = await safeFetch(url, { ...options, maxSize: maxBlobSize, timeout: timeoutMs });
+
+
if (!response.ok) {
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+
}
+
+
const reader = response.body?.getReader();
+
if (!reader) {
+
throw new Error('No response body');
+
}
+
+
const chunks: Uint8Array[] = [];
+
let totalSize = 0;
+
+
try {
+
while (true) {
+
const { done, value } = await reader.read();
+
if (done) break;
+
+
totalSize += value.length;
+
if (totalSize > maxBlobSize) {
+
throw new Error(`Blob exceeds max size: ${maxBlobSize} bytes`);
+
}
+
+
chunks.push(value);
+
}
+
} finally {
+
reader.releaseLock();
+
}
+
+
const combined = new Uint8Array(totalSize);
+
let offset = 0;
+
for (const chunk of chunks) {
+
combined.set(chunk, offset);
+
offset += chunk.length;
+
}
+
+
return combined;
+
}
+9
packages/@wisp/safe-fetch/tsconfig.json
···
···
+
{
+
"extends": "../../../tsconfig.json",
+
"compilerOptions": {
+
"outDir": "./dist",
+
"rootDir": "./src"
+
},
+
"include": ["src/**/*"],
+
"exclude": ["node_modules", "dist"]
+
}
public/acceptable-use/acceptable-use.tsx apps/main-app/public/acceptable-use/acceptable-use.tsx
public/acceptable-use/index.html apps/main-app/public/acceptable-use/index.html
public/admin/admin.tsx apps/main-app/public/admin/admin.tsx
public/admin/index.html apps/main-app/public/admin/index.html
public/admin/styles.css apps/main-app/public/admin/styles.css
public/android-chrome-192x192.png apps/main-app/public/android-chrome-192x192.png
public/android-chrome-512x512.png apps/main-app/public/android-chrome-512x512.png
public/apple-touch-icon.png apps/main-app/public/apple-touch-icon.png
public/components/ui/badge.tsx apps/main-app/public/components/ui/badge.tsx
public/components/ui/button.tsx apps/main-app/public/components/ui/button.tsx
public/components/ui/card.tsx apps/main-app/public/components/ui/card.tsx
public/components/ui/checkbox.tsx apps/main-app/public/components/ui/checkbox.tsx
public/components/ui/code-block.tsx apps/main-app/public/components/ui/code-block.tsx
public/components/ui/dialog.tsx apps/main-app/public/components/ui/dialog.tsx
public/components/ui/input.tsx apps/main-app/public/components/ui/input.tsx
public/components/ui/label.tsx apps/main-app/public/components/ui/label.tsx
public/components/ui/radio-group.tsx apps/main-app/public/components/ui/radio-group.tsx
public/components/ui/skeleton.tsx apps/main-app/public/components/ui/skeleton.tsx
public/components/ui/tabs.tsx apps/main-app/public/components/ui/tabs.tsx
public/editor/components/TabSkeleton.tsx apps/main-app/public/editor/components/TabSkeleton.tsx
public/editor/editor.tsx apps/main-app/public/editor/editor.tsx
public/editor/hooks/useDomainData.ts apps/main-app/public/editor/hooks/useDomainData.ts
public/editor/hooks/useSiteData.ts apps/main-app/public/editor/hooks/useSiteData.ts
public/editor/hooks/useUserInfo.ts apps/main-app/public/editor/hooks/useUserInfo.ts
public/editor/index.html apps/main-app/public/editor/index.html
public/editor/tabs/CLITab.tsx apps/main-app/public/editor/tabs/CLITab.tsx
public/editor/tabs/DomainsTab.tsx apps/main-app/public/editor/tabs/DomainsTab.tsx
public/editor/tabs/SitesTab.tsx apps/main-app/public/editor/tabs/SitesTab.tsx
public/editor/tabs/UploadTab.tsx apps/main-app/public/editor/tabs/UploadTab.tsx
public/favicon-16x16.png apps/main-app/public/favicon-16x16.png
public/favicon-32x32.png apps/main-app/public/favicon-32x32.png
public/favicon.ico apps/main-app/public/favicon.ico
public/index.html apps/main-app/public/index.html
public/index.tsx apps/main-app/public/index.tsx
public/layouts/index.tsx apps/main-app/public/layouts/index.tsx
public/lib/api.ts apps/main-app/public/lib/api.ts
public/lib/utils.ts apps/main-app/public/lib/utils.ts
public/onboarding/index.html apps/main-app/public/onboarding/index.html
public/onboarding/onboarding.tsx apps/main-app/public/onboarding/onboarding.tsx
public/robots.txt apps/main-app/public/robots.txt
public/screenshots/atproto-ui_wisp_place.png apps/main-app/public/screenshots/atproto-ui_wisp_place.png
public/screenshots/avalanche_moe.png apps/main-app/public/screenshots/avalanche_moe.png
public/screenshots/brotosolar_wisp_place.png apps/main-app/public/screenshots/brotosolar_wisp_place.png
public/screenshots/erisa_wisp_place.png apps/main-app/public/screenshots/erisa_wisp_place.png
public/screenshots/hayden_moe.png apps/main-app/public/screenshots/hayden_moe.png
public/screenshots/kot_pink.png apps/main-app/public/screenshots/kot_pink.png
public/screenshots/moover_wisp_place.png apps/main-app/public/screenshots/moover_wisp_place.png
public/screenshots/nekomimi_pet.png apps/main-app/public/screenshots/nekomimi_pet.png
public/screenshots/pdsls_wisp_place.png apps/main-app/public/screenshots/pdsls_wisp_place.png
public/screenshots/plc-bench_wisp_place.png apps/main-app/public/screenshots/plc-bench_wisp_place.png
public/screenshots/rainygoo_se.png apps/main-app/public/screenshots/rainygoo_se.png
public/screenshots/rd_jbcrn_dev.png apps/main-app/public/screenshots/rd_jbcrn_dev.png
public/screenshots/sites_wisp_place_did_plc_3whdb534faiczugsz5fnohh6_rafa.png apps/main-app/public/screenshots/sites_wisp_place_did_plc_3whdb534faiczugsz5fnohh6_rafa.png
public/screenshots/sites_wisp_place_did_plc_524tuhdhh3m7li5gycdn6boe_plcbundle-watch.png apps/main-app/public/screenshots/sites_wisp_place_did_plc_524tuhdhh3m7li5gycdn6boe_plcbundle-watch.png
public/screenshots/system_grdnsys_no.png apps/main-app/public/screenshots/system_grdnsys_no.png
public/screenshots/tealfm_indexx_dev.png apps/main-app/public/screenshots/tealfm_indexx_dev.png
public/screenshots/tigwyk_wisp_place.png apps/main-app/public/screenshots/tigwyk_wisp_place.png
public/screenshots/wfr_jbc_lol.png apps/main-app/public/screenshots/wfr_jbc_lol.png
public/screenshots/wisp_jbc_lol.png apps/main-app/public/screenshots/wisp_jbc_lol.png
public/screenshots/wisp_soverth_f5_si.png apps/main-app/public/screenshots/wisp_soverth_f5_si.png
public/screenshots/www_miriscient_org.png apps/main-app/public/screenshots/www_miriscient_org.png
public/screenshots/www_wlo_moe.png apps/main-app/public/screenshots/www_wlo_moe.png
public/site.webmanifest apps/main-app/public/site.webmanifest
public/styles/global.css apps/main-app/public/styles/global.css
public/transparent-full-size-ico.png apps/main-app/public/transparent-full-size-ico.png
scripts/change-admin-password.ts apps/main-app/scripts/change-admin-password.ts
+1 -1
scripts/create-admin.ts apps/main-app/scripts/create-admin.ts
···
// Quick script to create admin user with randomly generated password
-
import { adminAuth } from './src/lib/admin-auth'
import { randomBytes } from 'crypto'
// Generate a secure random password
···
// Quick script to create admin user with randomly generated password
+
import { adminAuth } from '../src/lib/admin-auth'
import { randomBytes } from 'crypto'
// Generate a secure random password
scripts/screenshot-sites.ts apps/main-app/scripts/screenshot-sites.ts
+7 -3
src/index.ts apps/main-app/src/index.ts
···
import { staticPlugin } from '@elysiajs/static'
import type { Config } from './lib/types'
-
import { BASE_HOST } from './lib/constants'
import {
createClientMetadata,
getOAuthClient,
···
import { siteRoutes } from './routes/site'
import { csrfProtection } from './lib/csrf'
import { DNSVerificationWorker } from './lib/dns-verification-worker'
-
import { logger, logCollector, observabilityMiddleware } from './lib/observability'
import { promptAdminSetup } from './lib/admin-auth'
import { adminRoutes } from './routes/admin'
const config: Config = {
domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as Config['domain'],
···
.use(adminRoutes(cookieSecret))
.use(
await staticPlugin({
prefix: '/'
})
)
···
const glob = new Glob('*.png')
const screenshots: string[] = []
-
for await (const file of glob.scan('./public/screenshots')) {
screenshots.push(file)
}
···
import { staticPlugin } from '@elysiajs/static'
import type { Config } from './lib/types'
+
import { BASE_HOST } from '@wisp/constants'
import {
createClientMetadata,
getOAuthClient,
···
import { siteRoutes } from './routes/site'
import { csrfProtection } from './lib/csrf'
import { DNSVerificationWorker } from './lib/dns-verification-worker'
+
import { createLogger, logCollector } from '@wisp/observability'
+
import { observabilityMiddleware } from '@wisp/observability/middleware/elysia'
import { promptAdminSetup } from './lib/admin-auth'
import { adminRoutes } from './routes/admin'
+
+
const logger = createLogger('main-app')
const config: Config = {
domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as Config['domain'],
···
.use(adminRoutes(cookieSecret))
.use(
await staticPlugin({
+
assets: 'apps/main-app/public',
prefix: '/'
})
)
···
const glob = new Glob('*.png')
const screenshots: string[] = []
+
for await (const file of glob.scan('./apps/main-app/public/screenshots')) {
screenshots.push(file)
}
+1 -1
src/lexicons/index.ts packages/@wisp/lexicons/src/index.ts
···
type MethodConfigOrHandler,
createServer as createXrpcServer,
} from '@atproto/xrpc-server'
-
import { schemas } from './lexicons.js'
export function createServer(options?: XrpcOptions): Server {
return new Server(options)
···
type MethodConfigOrHandler,
createServer as createXrpcServer,
} from '@atproto/xrpc-server'
+
import { schemas } from './lexicons'
export function createServer(options?: XrpcOptions): Server {
return new Server(options)
+1 -1
src/lexicons/lexicons.ts packages/@wisp/lexicons/src/lexicons.ts
···
ValidationError,
type ValidationResult,
} from '@atproto/lexicon'
-
import { type $Typed, is$typed, maybe$typed } from './util.js'
export const schemaDict = {
PlaceWispFs: {
···
ValidationError,
type ValidationResult,
} from '@atproto/lexicon'
+
import { type $Typed, is$typed, maybe$typed } from './util'
export const schemaDict = {
PlaceWispFs: {
-110
src/lexicons/types/place/wisp/fs.ts
···
-
/**
-
* GENERATED CODE - DO NOT MODIFY
-
*/
-
import { type ValidationResult, BlobRef } from '@atproto/lexicon'
-
import { CID } from 'multiformats/cid'
-
import { validate as _validate } from '../../../lexicons'
-
import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util'
-
-
const is$typed = _is$typed,
-
validate = _validate
-
const id = 'place.wisp.fs'
-
-
export interface Main {
-
$type: 'place.wisp.fs'
-
site: string
-
root: Directory
-
fileCount?: number
-
createdAt: string
-
[k: string]: unknown
-
}
-
-
const hashMain = 'main'
-
-
export function isMain<V>(v: V) {
-
return is$typed(v, id, hashMain)
-
}
-
-
export function validateMain<V>(v: V) {
-
return validate<Main & V>(v, id, hashMain, true)
-
}
-
-
export {
-
type Main as Record,
-
isMain as isRecord,
-
validateMain as validateRecord,
-
}
-
-
export interface File {
-
$type?: 'place.wisp.fs#file'
-
type: 'file'
-
/** Content blob ref */
-
blob: BlobRef
-
/** Content encoding (e.g., gzip for compressed files) */
-
encoding?: 'gzip'
-
/** Original MIME type before compression */
-
mimeType?: string
-
/** True if blob content is base64-encoded (used to bypass PDS content sniffing) */
-
base64?: boolean
-
}
-
-
const hashFile = 'file'
-
-
export function isFile<V>(v: V) {
-
return is$typed(v, id, hashFile)
-
}
-
-
export function validateFile<V>(v: V) {
-
return validate<File & V>(v, id, hashFile)
-
}
-
-
export interface Directory {
-
$type?: 'place.wisp.fs#directory'
-
type: 'directory'
-
entries: Entry[]
-
}
-
-
const hashDirectory = 'directory'
-
-
export function isDirectory<V>(v: V) {
-
return is$typed(v, id, hashDirectory)
-
}
-
-
export function validateDirectory<V>(v: V) {
-
return validate<Directory & V>(v, id, hashDirectory)
-
}
-
-
export interface Entry {
-
$type?: 'place.wisp.fs#entry'
-
name: string
-
node: $Typed<File> | $Typed<Directory> | $Typed<Subfs> | { $type: string }
-
}
-
-
const hashEntry = 'entry'
-
-
export function isEntry<V>(v: V) {
-
return is$typed(v, id, hashEntry)
-
}
-
-
export function validateEntry<V>(v: V) {
-
return validate<Entry & V>(v, id, hashEntry)
-
}
-
-
export interface Subfs {
-
$type?: 'place.wisp.fs#subfs'
-
type: 'subfs'
-
/** AT-URI pointing to a place.wisp.subfs record containing this subtree. */
-
subject: string
-
/** If true (default), the subfs record's root entries are merged (flattened) into the parent directory, replacing the subfs entry. If false, the subfs entries are placed in a subdirectory with the subfs entry's name. Flat merging is useful for splitting large directories across multiple records while maintaining a flat structure. */
-
flat?: boolean
-
}
-
-
const hashSubfs = 'subfs'
-
-
export function isSubfs<V>(v: V) {
-
return is$typed(v, id, hashSubfs)
-
}
-
-
export function validateSubfs<V>(v: V) {
-
return validate<Subfs & V>(v, id, hashSubfs)
-
}
···
-65
src/lexicons/types/place/wisp/settings.ts
···
-
/**
-
* GENERATED CODE - DO NOT MODIFY
-
*/
-
import { type ValidationResult, BlobRef } from '@atproto/lexicon'
-
import { CID } from 'multiformats/cid'
-
import { validate as _validate } from '../../../lexicons'
-
import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util'
-
-
const is$typed = _is$typed,
-
validate = _validate
-
const id = 'place.wisp.settings'
-
-
export interface Main {
-
$type: 'place.wisp.settings'
-
/** Enable directory listing mode for paths that resolve to directories without an index file. Incompatible with spaMode. */
-
directoryListing: boolean
-
/** File to serve for all routes (e.g., 'index.html'). When set, enables SPA mode where all non-file requests are routed to this file. Incompatible with directoryListing and custom404. */
-
spaMode?: string
-
/** Custom 404 error page file path. Incompatible with directoryListing and spaMode. */
-
custom404?: string
-
/** Ordered list of files to try when serving a directory. Defaults to ['index.html'] if not specified. */
-
indexFiles?: string[]
-
/** Enable clean URL routing. When enabled, '/about' will attempt to serve '/about.html' or '/about/index.html' automatically. */
-
cleanUrls: boolean
-
/** Custom HTTP headers to set on responses */
-
headers?: CustomHeader[]
-
[k: string]: unknown
-
}
-
-
const hashMain = 'main'
-
-
export function isMain<V>(v: V) {
-
return is$typed(v, id, hashMain)
-
}
-
-
export function validateMain<V>(v: V) {
-
return validate<Main & V>(v, id, hashMain, true)
-
}
-
-
export {
-
type Main as Record,
-
isMain as isRecord,
-
validateMain as validateRecord,
-
}
-
-
/** Custom HTTP header configuration */
-
export interface CustomHeader {
-
$type?: 'place.wisp.settings#customHeader'
-
/** HTTP header name (e.g., 'Cache-Control', 'X-Frame-Options') */
-
name: string
-
/** HTTP header value */
-
value: string
-
/** Optional glob pattern to apply this header to specific paths (e.g., '*.html', '/assets/*'). If not specified, applies to all paths. */
-
path?: string
-
}
-
-
const hashCustomHeader = 'customHeader'
-
-
export function isCustomHeader<V>(v: V) {
-
return is$typed(v, id, hashCustomHeader)
-
}
-
-
export function validateCustomHeader<V>(v: V) {
-
return validate<CustomHeader & V>(v, id, hashCustomHeader)
-
}
···
-107
src/lexicons/types/place/wisp/subfs.ts
···
-
/**
-
* GENERATED CODE - DO NOT MODIFY
-
*/
-
import { type ValidationResult, BlobRef } from '@atproto/lexicon'
-
import { CID } from 'multiformats/cid'
-
import { validate as _validate } from '../../../lexicons'
-
import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util'
-
-
const is$typed = _is$typed,
-
validate = _validate
-
const id = 'place.wisp.subfs'
-
-
export interface Main {
-
$type: 'place.wisp.subfs'
-
root: Directory
-
fileCount?: number
-
createdAt: string
-
[k: string]: unknown
-
}
-
-
const hashMain = 'main'
-
-
export function isMain<V>(v: V) {
-
return is$typed(v, id, hashMain)
-
}
-
-
export function validateMain<V>(v: V) {
-
return validate<Main & V>(v, id, hashMain, true)
-
}
-
-
export {
-
type Main as Record,
-
isMain as isRecord,
-
validateMain as validateRecord,
-
}
-
-
export interface File {
-
$type?: 'place.wisp.subfs#file'
-
type: 'file'
-
/** Content blob ref */
-
blob: BlobRef
-
/** Content encoding (e.g., gzip for compressed files) */
-
encoding?: 'gzip'
-
/** Original MIME type before compression */
-
mimeType?: string
-
/** True if blob content is base64-encoded (used to bypass PDS content sniffing) */
-
base64?: boolean
-
}
-
-
const hashFile = 'file'
-
-
export function isFile<V>(v: V) {
-
return is$typed(v, id, hashFile)
-
}
-
-
export function validateFile<V>(v: V) {
-
return validate<File & V>(v, id, hashFile)
-
}
-
-
export interface Directory {
-
$type?: 'place.wisp.subfs#directory'
-
type: 'directory'
-
entries: Entry[]
-
}
-
-
const hashDirectory = 'directory'
-
-
export function isDirectory<V>(v: V) {
-
return is$typed(v, id, hashDirectory)
-
}
-
-
export function validateDirectory<V>(v: V) {
-
return validate<Directory & V>(v, id, hashDirectory)
-
}
-
-
export interface Entry {
-
$type?: 'place.wisp.subfs#entry'
-
name: string
-
node: $Typed<File> | $Typed<Directory> | $Typed<Subfs> | { $type: string }
-
}
-
-
const hashEntry = 'entry'
-
-
export function isEntry<V>(v: V) {
-
return is$typed(v, id, hashEntry)
-
}
-
-
export function validateEntry<V>(v: V) {
-
return validate<Entry & V>(v, id, hashEntry)
-
}
-
-
export interface Subfs {
-
$type?: 'place.wisp.subfs#subfs'
-
type: 'subfs'
-
/** AT-URI pointing to another place.wisp.subfs record for nested subtrees. When expanded, the referenced record's root entries are merged (flattened) into the parent directory, allowing recursive splitting of large directory structures. */
-
subject: string
-
}
-
-
const hashSubfs = 'subfs'
-
-
export function isSubfs<V>(v: V) {
-
return is$typed(v, id, hashSubfs)
-
}
-
-
export function validateSubfs<V>(v: V) {
-
return validate<Subfs & V>(v, id, hashSubfs)
-
}
···
-82
src/lexicons/util.ts
···
-
/**
-
* GENERATED CODE - DO NOT MODIFY
-
*/
-
-
import { type ValidationResult } from '@atproto/lexicon'
-
-
export type OmitKey<T, K extends keyof T> = {
-
[K2 in keyof T as K2 extends K ? never : K2]: T[K2]
-
}
-
-
export type $Typed<V, T extends string = string> = V & { $type: T }
-
export type Un$Typed<V extends { $type?: string }> = OmitKey<V, '$type'>
-
-
export type $Type<Id extends string, Hash extends string> = Hash extends 'main'
-
? Id
-
: `${Id}#${Hash}`
-
-
function isObject<V>(v: V): v is V & object {
-
return v != null && typeof v === 'object'
-
}
-
-
function is$type<Id extends string, Hash extends string>(
-
$type: unknown,
-
id: Id,
-
hash: Hash,
-
): $type is $Type<Id, Hash> {
-
return hash === 'main'
-
? $type === id
-
: // $type === `${id}#${hash}`
-
typeof $type === 'string' &&
-
$type.length === id.length + 1 + hash.length &&
-
$type.charCodeAt(id.length) === 35 /* '#' */ &&
-
$type.startsWith(id) &&
-
$type.endsWith(hash)
-
}
-
-
export type $TypedObject<
-
V,
-
Id extends string,
-
Hash extends string,
-
> = V extends {
-
$type: $Type<Id, Hash>
-
}
-
? V
-
: V extends { $type?: string }
-
? V extends { $type?: infer T extends $Type<Id, Hash> }
-
? V & { $type: T }
-
: never
-
: V & { $type: $Type<Id, Hash> }
-
-
export function is$typed<V, Id extends string, Hash extends string>(
-
v: V,
-
id: Id,
-
hash: Hash,
-
): v is $TypedObject<V, Id, Hash> {
-
return isObject(v) && '$type' in v && is$type(v.$type, id, hash)
-
}
-
-
export function maybe$typed<V, Id extends string, Hash extends string>(
-
v: V,
-
id: Id,
-
hash: Hash,
-
): v is V & object & { $type?: $Type<Id, Hash> } {
-
return (
-
isObject(v) &&
-
('$type' in v ? v.$type === undefined || is$type(v.$type, id, hash) : true)
-
)
-
}
-
-
export type Validator<R = unknown> = (v: unknown) => ValidationResult<R>
-
export type ValidatorParam<V extends Validator> =
-
V extends Validator<infer R> ? R : never
-
-
/**
-
* Utility function that allows to convert a "validate*" utility function into a
-
* type predicate.
-
*/
-
export function asPredicate<V extends Validator>(validate: V) {
-
return function <T>(v: T): v is T & ValidatorParam<V> {
-
return validate(v).success
-
}
-
}
···
src/lib/admin-auth.ts apps/main-app/src/lib/admin-auth.ts
-4
src/lib/constants.ts
···
-
export const BASE_HOST = Bun.env.BASE_DOMAIN || "wisp.place";
-
export const MAX_SITE_SIZE = 300 * 1024 * 1024; //300MB
-
export const MAX_FILE_SIZE = 100 * 1024 * 1024; //100MB
-
export const MAX_FILE_COUNT = 1000;
···
src/lib/csrf.test.ts apps/main-app/src/lib/csrf.test.ts
src/lib/csrf.ts apps/main-app/src/lib/csrf.ts
src/lib/db.test.ts apps/main-app/src/lib/db.test.ts
+1 -1
src/lib/db.ts apps/main-app/src/lib/db.ts
···
import { SQL } from "bun";
-
import { BASE_HOST } from "./constants";
export const db = new SQL(
process.env.NODE_ENV === 'production'
···
import { SQL } from "bun";
+
import { BASE_HOST } from "@wisp/constants";
export const db = new SQL(
process.env.NODE_ENV === 'production'
src/lib/dns-verification-worker.ts apps/main-app/src/lib/dns-verification-worker.ts
src/lib/dns-verify.ts apps/main-app/src/lib/dns-verify.ts
-46
src/lib/logger.ts
···
-
// Secure logging utility - only verbose in development mode
-
const isDev = process.env.NODE_ENV !== 'production';
-
-
export const logger = {
-
// Always log these (safe for production)
-
info: (...args: any[]) => {
-
console.log(...args);
-
},
-
-
// Only log in development (may contain sensitive info)
-
debug: (...args: any[]) => {
-
if (isDev) {
-
console.debug(...args);
-
}
-
},
-
-
// Warning logging (always logged but may be sanitized in production)
-
warn: (message: string, context?: Record<string, any>) => {
-
if (isDev) {
-
console.warn(message, context);
-
} else {
-
console.warn(message);
-
}
-
},
-
-
// Safe error logging - sanitizes in production
-
error: (message: string, error?: any) => {
-
if (isDev) {
-
// Development: log full error details
-
console.error(message, error);
-
} else {
-
// Production: log only the message, not error details
-
console.error(message);
-
}
-
},
-
-
// Log error with context but sanitize sensitive data in production
-
errorWithContext: (message: string, context?: Record<string, any>, error?: any) => {
-
if (isDev) {
-
console.error(message, context, error);
-
} else {
-
// In production, only log the message
-
console.error(message);
-
}
-
}
-
};
···
+1 -1
src/lib/oauth-client.ts apps/main-app/src/lib/oauth-client.ts
···
// Check if expired
const expiresAt = Number(result[0].expires_at);
if (expiresAt && now > expiresAt) {
-
logger.debug('[sessionStore] Session expired, deleting', sub);
await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`;
return undefined;
}
···
// Check if expired
const expiresAt = Number(result[0].expires_at);
if (expiresAt && now > expiresAt) {
+
logger.debug('[sessionStore] Session expired, deleting', { sub });
await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`;
return undefined;
}
-339
src/lib/observability.ts
···
-
// DIY Observability - Logs, Metrics, and Error Tracking
-
// Types
-
export interface LogEntry {
-
id: string
-
timestamp: Date
-
level: 'info' | 'warn' | 'error' | 'debug'
-
message: string
-
service: string
-
context?: Record<string, any>
-
traceId?: string
-
eventType?: string
-
}
-
-
export interface ErrorEntry {
-
id: string
-
timestamp: Date
-
message: string
-
stack?: string
-
service: string
-
context?: Record<string, any>
-
count: number // How many times this error occurred
-
lastSeen: Date
-
}
-
-
export interface MetricEntry {
-
timestamp: Date
-
path: string
-
method: string
-
statusCode: number
-
duration: number // in milliseconds
-
service: string
-
}
-
-
export interface DatabaseStats {
-
totalSites: number
-
totalDomains: number
-
totalCustomDomains: number
-
recentSites: any[]
-
recentDomains: any[]
-
}
-
-
// In-memory storage with rotation
-
const MAX_LOGS = 5000
-
const MAX_ERRORS = 500
-
const MAX_METRICS = 10000
-
-
const logs: LogEntry[] = []
-
const errors: Map<string, ErrorEntry> = new Map()
-
const metrics: MetricEntry[] = []
-
-
// Helper to generate unique IDs
-
let logCounter = 0
-
let errorCounter = 0
-
-
function generateId(prefix: string, counter: number): string {
-
return `${prefix}-${Date.now()}-${counter}`
-
}
-
-
// Helper to extract event type from message
-
function extractEventType(message: string): string | undefined {
-
const match = message.match(/^\[([^\]]+)\]/)
-
return match ? match[1] : undefined
-
}
-
-
// Log collector
-
export const logCollector = {
-
log(level: LogEntry['level'], message: string, service: string, context?: Record<string, any>, traceId?: string) {
-
const entry: LogEntry = {
-
id: generateId('log', logCounter++),
-
timestamp: new Date(),
-
level,
-
message,
-
service,
-
context,
-
traceId,
-
eventType: extractEventType(message)
-
}
-
-
logs.unshift(entry)
-
-
// Rotate if needed
-
if (logs.length > MAX_LOGS) {
-
logs.splice(MAX_LOGS)
-
}
-
-
// Also log to console for compatibility
-
const contextStr = context ? ` ${JSON.stringify(context)}` : ''
-
const traceStr = traceId ? ` [trace:${traceId}]` : ''
-
console[level === 'debug' ? 'log' : level](`[${service}] ${message}${contextStr}${traceStr}`)
-
},
-
-
info(message: string, service: string, context?: Record<string, any>, traceId?: string) {
-
this.log('info', message, service, context, traceId)
-
},
-
-
warn(message: string, service: string, context?: Record<string, any>, traceId?: string) {
-
this.log('warn', message, service, context, traceId)
-
},
-
-
error(message: string, service: string, error?: any, context?: Record<string, any>, traceId?: string) {
-
const ctx = { ...context }
-
if (error instanceof Error) {
-
ctx.error = error.message
-
ctx.stack = error.stack
-
} else if (error) {
-
ctx.error = String(error)
-
}
-
this.log('error', message, service, ctx, traceId)
-
-
// Also track in errors
-
errorTracker.track(message, service, error, context)
-
},
-
-
debug(message: string, service: string, context?: Record<string, any>, traceId?: string) {
-
if (process.env.NODE_ENV !== 'production') {
-
this.log('debug', message, service, context, traceId)
-
}
-
},
-
-
getLogs(filter?: { level?: string; service?: string; limit?: number; search?: string; eventType?: string }) {
-
let filtered = [...logs]
-
-
if (filter?.level) {
-
filtered = filtered.filter(log => log.level === filter.level)
-
}
-
-
if (filter?.service) {
-
filtered = filtered.filter(log => log.service === filter.service)
-
}
-
-
if (filter?.eventType) {
-
filtered = filtered.filter(log => log.eventType === filter.eventType)
-
}
-
-
if (filter?.search) {
-
const search = filter.search.toLowerCase()
-
filtered = filtered.filter(log =>
-
log.message.toLowerCase().includes(search) ||
-
(log.context ? JSON.stringify(log.context).toLowerCase().includes(search) : false)
-
)
-
}
-
-
const limit = filter?.limit || 100
-
return filtered.slice(0, limit)
-
},
-
-
clear() {
-
logs.length = 0
-
}
-
}
-
-
// Error tracker with deduplication
-
export const errorTracker = {
-
track(message: string, service: string, error?: any, context?: Record<string, any>) {
-
const key = `${service}:${message}`
-
-
const existing = errors.get(key)
-
if (existing) {
-
existing.count++
-
existing.lastSeen = new Date()
-
if (context) {
-
existing.context = { ...existing.context, ...context }
-
}
-
} else {
-
const entry: ErrorEntry = {
-
id: generateId('error', errorCounter++),
-
timestamp: new Date(),
-
message,
-
service,
-
context,
-
count: 1,
-
lastSeen: new Date()
-
}
-
-
if (error instanceof Error) {
-
entry.stack = error.stack
-
}
-
-
errors.set(key, entry)
-
-
// Rotate if needed
-
if (errors.size > MAX_ERRORS) {
-
const oldest = Array.from(errors.keys())[0]
-
errors.delete(oldest)
-
}
-
}
-
},
-
-
getErrors(filter?: { service?: string; limit?: number }) {
-
let filtered = Array.from(errors.values())
-
-
if (filter?.service) {
-
filtered = filtered.filter(err => err.service === filter.service)
-
}
-
-
// Sort by last seen (most recent first)
-
filtered.sort((a, b) => b.lastSeen.getTime() - a.lastSeen.getTime())
-
-
const limit = filter?.limit || 100
-
return filtered.slice(0, limit)
-
},
-
-
clear() {
-
errors.clear()
-
}
-
}
-
-
// Metrics collector
-
export const metricsCollector = {
-
recordRequest(path: string, method: string, statusCode: number, duration: number, service: string) {
-
const entry: MetricEntry = {
-
timestamp: new Date(),
-
path,
-
method,
-
statusCode,
-
duration,
-
service
-
}
-
-
metrics.unshift(entry)
-
-
// Rotate if needed
-
if (metrics.length > MAX_METRICS) {
-
metrics.splice(MAX_METRICS)
-
}
-
},
-
-
getMetrics(filter?: { service?: string; timeWindow?: number }) {
-
let filtered = [...metrics]
-
-
if (filter?.service) {
-
filtered = filtered.filter(m => m.service === filter.service)
-
}
-
-
if (filter?.timeWindow) {
-
const cutoff = Date.now() - filter.timeWindow
-
filtered = filtered.filter(m => m.timestamp.getTime() > cutoff)
-
}
-
-
return filtered
-
},
-
-
getStats(service?: string, timeWindow: number = 3600000) {
-
const filtered = this.getMetrics({ service, timeWindow })
-
-
if (filtered.length === 0) {
-
return {
-
totalRequests: 0,
-
avgDuration: 0,
-
p50Duration: 0,
-
p95Duration: 0,
-
p99Duration: 0,
-
errorRate: 0,
-
requestsPerMinute: 0
-
}
-
}
-
-
const durations = filtered.map(m => m.duration).sort((a, b) => a - b)
-
const totalDuration = durations.reduce((sum, d) => sum + d, 0)
-
const errors = filtered.filter(m => m.statusCode >= 400).length
-
-
const p50 = durations[Math.floor(durations.length * 0.5)]
-
const p95 = durations[Math.floor(durations.length * 0.95)]
-
const p99 = durations[Math.floor(durations.length * 0.99)]
-
-
const timeWindowMinutes = timeWindow / 60000
-
-
return {
-
totalRequests: filtered.length,
-
avgDuration: Math.round(totalDuration / filtered.length),
-
p50Duration: Math.round(p50),
-
p95Duration: Math.round(p95),
-
p99Duration: Math.round(p99),
-
errorRate: (errors / filtered.length) * 100,
-
requestsPerMinute: Math.round(filtered.length / timeWindowMinutes)
-
}
-
},
-
-
clear() {
-
metrics.length = 0
-
}
-
}
-
-
// Elysia middleware for request timing
-
export function observabilityMiddleware(service: string) {
-
return {
-
beforeHandle: ({ request }: any) => {
-
// Store start time on request object
-
(request as any).__startTime = Date.now()
-
},
-
afterHandle: ({ request, set }: any) => {
-
const duration = Date.now() - ((request as any).__startTime || Date.now())
-
const url = new URL(request.url)
-
-
metricsCollector.recordRequest(
-
url.pathname,
-
request.method,
-
set.status || 200,
-
duration,
-
service
-
)
-
},
-
onError: ({ request, error, set }: any) => {
-
const duration = Date.now() - ((request as any).__startTime || Date.now())
-
const url = new URL(request.url)
-
-
metricsCollector.recordRequest(
-
url.pathname,
-
request.method,
-
set.status || 500,
-
duration,
-
service
-
)
-
-
// Don't log 404 errors
-
const statusCode = set.status || 500
-
if (statusCode !== 404) {
-
logCollector.error(
-
`Request failed: ${request.method} ${url.pathname}`,
-
service,
-
error,
-
{ statusCode }
-
)
-
}
-
}
-
}
-
}
-
-
// Export singleton logger for easy access
-
export const logger = {
-
info: (message: string, context?: Record<string, any>) =>
-
logCollector.info(message, 'main-app', context),
-
warn: (message: string, context?: Record<string, any>) =>
-
logCollector.warn(message, 'main-app', context),
-
error: (message: string, error?: any, context?: Record<string, any>) =>
-
logCollector.error(message, 'main-app', error, context),
-
debug: (message: string, context?: Record<string, any>) =>
-
logCollector.debug(message, 'main-app', context)
-
}
···
+2 -2
src/lib/slingshot-handle-resolver.ts apps/main-app/src/lib/slingshot-handle-resolver.ts
···
* to work around bugs in atproto-oauth-node when handles have redirects
* in their well-known configuration.
*
-
* Uses: https://slingshot.microcosm.blue/xrpc/com.atproto.identity.resolveHandle
*/
export class SlingshotHandleResolver implements HandleResolver {
-
private readonly endpoint = 'https://slingshot.microcosm.blue/xrpc/com.atproto.identity.resolveHandle';
async resolve(handle: string, options?: ResolveHandleOptions): Promise<ResolvedHandle> {
try {
···
* to work around bugs in atproto-oauth-node when handles have redirects
* in their well-known configuration.
*
+
* Uses: https://slingshot.wisp.place/xrpc/com.atproto.identity.resolveHandle
*/
export class SlingshotHandleResolver implements HandleResolver {
+
private readonly endpoint = 'https://slingshot.wisp.place/xrpc/com.atproto.identity.resolveHandle';
async resolve(handle: string, options?: ResolveHandleOptions): Promise<ResolvedHandle> {
try {
src/lib/sync-sites.ts apps/main-app/src/lib/sync-sites.ts
src/lib/types.ts apps/main-app/src/lib/types.ts
+3 -1
src/lib/upload-jobs.ts apps/main-app/src/lib/upload-jobs.ts
···
-
import { logger } from './observability';
export type UploadJobStatus = 'pending' | 'processing' | 'uploading' | 'completed' | 'failed';
···
+
import { createLogger } from '@wisp/observability';
+
+
const logger = createLogger('main-app');
export type UploadJobStatus = 'pending' | 'processing' | 'uploading' | 'completed' | 'failed';
src/lib/wisp-auth.ts apps/main-app/src/lib/wisp-auth.ts
-1005
src/lib/wisp-utils.test.ts
···
-
import { describe, test, expect } from 'bun:test'
-
import {
-
shouldCompressFile,
-
compressFile,
-
processUploadedFiles,
-
createManifest,
-
updateFileBlobs,
-
computeCID,
-
extractBlobMap,
-
type UploadedFile,
-
type FileUploadResult,
-
} from './wisp-utils'
-
import type { Directory } from '../lexicons/types/place/wisp/fs'
-
import { gunzipSync } from 'zlib'
-
import { BlobRef } from '@atproto/api'
-
import { CID } from 'multiformats/cid'
-
-
// Helper function to create a valid CID for testing
-
// Using a real valid CID from actual AT Protocol usage
-
const TEST_CID_STRING = 'bafkreid7ybejd5s2vv2j7d4aajjlmdgazguemcnuliiyfn6coxpwp2mi6y'
-
-
function createMockBlobRef(mimeType: string, size: number): BlobRef {
-
// Create a properly formatted CID
-
const cid = CID.parse(TEST_CID_STRING)
-
return new BlobRef(cid as any, mimeType, size)
-
}
-
-
describe('shouldCompressFile', () => {
-
test('should compress HTML files', () => {
-
expect(shouldCompressFile('text/html')).toBe(true)
-
expect(shouldCompressFile('text/html; charset=utf-8')).toBe(true)
-
})
-
-
test('should compress CSS files', () => {
-
expect(shouldCompressFile('text/css')).toBe(true)
-
})
-
-
test('should compress JavaScript files', () => {
-
expect(shouldCompressFile('text/javascript')).toBe(true)
-
expect(shouldCompressFile('application/javascript')).toBe(true)
-
expect(shouldCompressFile('application/x-javascript')).toBe(true)
-
})
-
-
test('should compress JSON files', () => {
-
expect(shouldCompressFile('application/json')).toBe(true)
-
})
-
-
test('should compress SVG files', () => {
-
expect(shouldCompressFile('image/svg+xml')).toBe(true)
-
})
-
-
test('should compress XML files', () => {
-
expect(shouldCompressFile('text/xml')).toBe(true)
-
expect(shouldCompressFile('application/xml')).toBe(true)
-
})
-
-
test('should compress plain text files', () => {
-
expect(shouldCompressFile('text/plain')).toBe(true)
-
})
-
-
test('should NOT compress _redirects file', () => {
-
expect(shouldCompressFile('text/plain', '_redirects')).toBe(false)
-
expect(shouldCompressFile('text/plain', 'folder/_redirects')).toBe(false)
-
expect(shouldCompressFile('application/octet-stream', '_redirects')).toBe(false)
-
})
-
-
test('should NOT compress images', () => {
-
expect(shouldCompressFile('image/png')).toBe(false)
-
expect(shouldCompressFile('image/jpeg')).toBe(false)
-
expect(shouldCompressFile('image/jpg')).toBe(false)
-
expect(shouldCompressFile('image/gif')).toBe(false)
-
expect(shouldCompressFile('image/webp')).toBe(false)
-
})
-
-
test('should NOT compress videos', () => {
-
expect(shouldCompressFile('video/mp4')).toBe(false)
-
expect(shouldCompressFile('video/webm')).toBe(false)
-
})
-
-
test('should NOT compress already compressed formats', () => {
-
expect(shouldCompressFile('application/zip')).toBe(false)
-
expect(shouldCompressFile('application/gzip')).toBe(false)
-
expect(shouldCompressFile('application/pdf')).toBe(false)
-
})
-
-
test('should NOT compress fonts', () => {
-
expect(shouldCompressFile('font/woff')).toBe(false)
-
expect(shouldCompressFile('font/woff2')).toBe(false)
-
expect(shouldCompressFile('font/ttf')).toBe(false)
-
})
-
})
-
-
describe('compressFile', () => {
-
test('should compress text content', () => {
-
const content = Buffer.from('Hello, World! '.repeat(100))
-
const compressed = compressFile(content)
-
-
expect(compressed.length).toBeLessThan(content.length)
-
-
// Verify we can decompress it back
-
const decompressed = gunzipSync(compressed)
-
expect(decompressed.toString()).toBe(content.toString())
-
})
-
-
test('should compress HTML content significantly', () => {
-
const html = `
-
<!DOCTYPE html>
-
<html>
-
<head><title>Test</title></head>
-
<body>
-
${'<p>Hello World!</p>\n'.repeat(50)}
-
</body>
-
</html>
-
`
-
const content = Buffer.from(html)
-
const compressed = compressFile(content)
-
-
expect(compressed.length).toBeLessThan(content.length)
-
-
// Verify decompression
-
const decompressed = gunzipSync(compressed)
-
expect(decompressed.toString()).toBe(html)
-
})
-
-
test('should handle empty content', () => {
-
const content = Buffer.from('')
-
const compressed = compressFile(content)
-
const decompressed = gunzipSync(compressed)
-
expect(decompressed.toString()).toBe('')
-
})
-
-
test('should produce deterministic compression', () => {
-
const content = Buffer.from('Test content')
-
const compressed1 = compressFile(content)
-
const compressed2 = compressFile(content)
-
-
expect(compressed1.toString('base64')).toBe(compressed2.toString('base64'))
-
})
-
})
-
-
describe('processUploadedFiles', () => {
-
test('should process single root-level file', () => {
-
const files: UploadedFile[] = [
-
{
-
name: 'index.html',
-
content: Buffer.from('<html></html>'),
-
mimeType: 'text/html',
-
size: 13,
-
},
-
]
-
-
const result = processUploadedFiles(files)
-
-
expect(result.fileCount).toBe(1)
-
expect(result.directory.type).toBe('directory')
-
expect(result.directory.entries).toHaveLength(1)
-
expect(result.directory.entries[0].name).toBe('index.html')
-
-
const node = result.directory.entries[0].node
-
expect('blob' in node).toBe(true) // It's a file node
-
})
-
-
test('should process multiple root-level files', () => {
-
const files: UploadedFile[] = [
-
{
-
name: 'index.html',
-
content: Buffer.from('<html></html>'),
-
mimeType: 'text/html',
-
size: 13,
-
},
-
{
-
name: 'styles.css',
-
content: Buffer.from('body {}'),
-
mimeType: 'text/css',
-
size: 7,
-
},
-
{
-
name: 'script.js',
-
content: Buffer.from('console.log("hi")'),
-
mimeType: 'application/javascript',
-
size: 17,
-
},
-
]
-
-
const result = processUploadedFiles(files)
-
-
expect(result.fileCount).toBe(3)
-
expect(result.directory.entries).toHaveLength(3)
-
-
const names = result.directory.entries.map(e => e.name)
-
expect(names).toContain('index.html')
-
expect(names).toContain('styles.css')
-
expect(names).toContain('script.js')
-
})
-
-
test('should process files with subdirectories', () => {
-
const files: UploadedFile[] = [
-
{
-
name: 'dist/index.html',
-
content: Buffer.from('<html></html>'),
-
mimeType: 'text/html',
-
size: 13,
-
},
-
{
-
name: 'dist/css/styles.css',
-
content: Buffer.from('body {}'),
-
mimeType: 'text/css',
-
size: 7,
-
},
-
{
-
name: 'dist/js/app.js',
-
content: Buffer.from('console.log()'),
-
mimeType: 'application/javascript',
-
size: 13,
-
},
-
]
-
-
const result = processUploadedFiles(files)
-
-
expect(result.fileCount).toBe(3)
-
expect(result.directory.entries).toHaveLength(3) // index.html, css/, js/
-
-
// Check root has index.html (after base folder removal)
-
const indexEntry = result.directory.entries.find(e => e.name === 'index.html')
-
expect(indexEntry).toBeDefined()
-
-
// Check css directory exists
-
const cssDir = result.directory.entries.find(e => e.name === 'css')
-
expect(cssDir).toBeDefined()
-
expect('entries' in cssDir!.node).toBe(true)
-
-
if ('entries' in cssDir!.node) {
-
expect(cssDir!.node.entries).toHaveLength(1)
-
expect(cssDir!.node.entries[0].name).toBe('styles.css')
-
}
-
-
// Check js directory exists
-
const jsDir = result.directory.entries.find(e => e.name === 'js')
-
expect(jsDir).toBeDefined()
-
expect('entries' in jsDir!.node).toBe(true)
-
})
-
-
test('should handle deeply nested subdirectories', () => {
-
const files: UploadedFile[] = [
-
{
-
name: 'dist/deep/nested/folder/file.txt',
-
content: Buffer.from('content'),
-
mimeType: 'text/plain',
-
size: 7,
-
},
-
]
-
-
const result = processUploadedFiles(files)
-
-
expect(result.fileCount).toBe(1)
-
-
// Navigate through the directory structure (base folder removed)
-
const deepDir = result.directory.entries.find(e => e.name === 'deep')
-
expect(deepDir).toBeDefined()
-
expect('entries' in deepDir!.node).toBe(true)
-
-
if ('entries' in deepDir!.node) {
-
const nestedDir = deepDir!.node.entries.find(e => e.name === 'nested')
-
expect(nestedDir).toBeDefined()
-
-
if (nestedDir && 'entries' in nestedDir.node) {
-
const folderDir = nestedDir.node.entries.find(e => e.name === 'folder')
-
expect(folderDir).toBeDefined()
-
-
if (folderDir && 'entries' in folderDir.node) {
-
expect(folderDir.node.entries).toHaveLength(1)
-
expect(folderDir.node.entries[0].name).toBe('file.txt')
-
}
-
}
-
}
-
})
-
-
test('should remove base folder name from paths', () => {
-
const files: UploadedFile[] = [
-
{
-
name: 'dist/index.html',
-
content: Buffer.from('<html></html>'),
-
mimeType: 'text/html',
-
size: 13,
-
},
-
{
-
name: 'dist/css/styles.css',
-
content: Buffer.from('body {}'),
-
mimeType: 'text/css',
-
size: 7,
-
},
-
]
-
-
const result = processUploadedFiles(files)
-
-
// After removing 'dist/', we should have index.html and css/ at root
-
expect(result.directory.entries.find(e => e.name === 'index.html')).toBeDefined()
-
expect(result.directory.entries.find(e => e.name === 'css')).toBeDefined()
-
expect(result.directory.entries.find(e => e.name === 'dist')).toBeUndefined()
-
})
-
-
test('should handle empty file list', () => {
-
const files: UploadedFile[] = []
-
const result = processUploadedFiles(files)
-
-
expect(result.fileCount).toBe(0)
-
expect(result.directory.entries).toHaveLength(0)
-
})
-
-
test('should handle multiple files in same subdirectory', () => {
-
const files: UploadedFile[] = [
-
{
-
name: 'dist/assets/image1.png',
-
content: Buffer.from('png1'),
-
mimeType: 'image/png',
-
size: 4,
-
},
-
{
-
name: 'dist/assets/image2.png',
-
content: Buffer.from('png2'),
-
mimeType: 'image/png',
-
size: 4,
-
},
-
]
-
-
const result = processUploadedFiles(files)
-
-
expect(result.fileCount).toBe(2)
-
-
const assetsDir = result.directory.entries.find(e => e.name === 'assets')
-
expect(assetsDir).toBeDefined()
-
-
if ('entries' in assetsDir!.node) {
-
expect(assetsDir!.node.entries).toHaveLength(2)
-
const names = assetsDir!.node.entries.map(e => e.name)
-
expect(names).toContain('image1.png')
-
expect(names).toContain('image2.png')
-
}
-
})
-
})
-
-
describe('createManifest', () => {
-
test('should create valid manifest', () => {
-
const root: Directory = {
-
$type: 'place.wisp.fs#directory',
-
type: 'directory',
-
entries: [],
-
}
-
-
const manifest = createManifest('example.com', root, 0)
-
-
expect(manifest.$type).toBe('place.wisp.fs')
-
expect(manifest.site).toBe('example.com')
-
expect(manifest.root).toBe(root)
-
expect(manifest.fileCount).toBe(0)
-
expect(manifest.createdAt).toBeDefined()
-
-
// Verify it's a valid ISO date string
-
const date = new Date(manifest.createdAt)
-
expect(date.toISOString()).toBe(manifest.createdAt)
-
})
-
-
test('should create manifest with file count', () => {
-
const root: Directory = {
-
$type: 'place.wisp.fs#directory',
-
type: 'directory',
-
entries: [],
-
}
-
-
const manifest = createManifest('test-site', root, 42)
-
-
expect(manifest.fileCount).toBe(42)
-
expect(manifest.site).toBe('test-site')
-
})
-
-
test('should create manifest with populated directory', () => {
-
const mockBlob = createMockBlobRef('text/html', 100)
-
-
const root: Directory = {
-
$type: 'place.wisp.fs#directory',
-
type: 'directory',
-
entries: [
-
{
-
name: 'index.html',
-
node: {
-
$type: 'place.wisp.fs#file',
-
type: 'file',
-
blob: mockBlob,
-
},
-
},
-
],
-
}
-
-
const manifest = createManifest('populated-site', root, 1)
-
-
expect(manifest).toBeDefined()
-
expect(manifest.site).toBe('populated-site')
-
expect(manifest.root.entries).toHaveLength(1)
-
})
-
})
-
-
describe('updateFileBlobs', () => {
-
test('should update single file blob at root', () => {
-
const directory: Directory = {
-
$type: 'place.wisp.fs#directory',
-
type: 'directory',
-
entries: [
-
{
-
name: 'index.html',
-
node: {
-
$type: 'place.wisp.fs#file',
-
type: 'file',
-
blob: undefined as any,
-
},
-
},
-
],
-
}
-
-
const mockBlob = createMockBlobRef('text/html', 100)
-
const uploadResults: FileUploadResult[] = [
-
{
-
hash: TEST_CID_STRING,
-
blobRef: mockBlob,
-
mimeType: 'text/html',
-
},
-
]
-
-
const filePaths = ['index.html']
-
-
const updated = updateFileBlobs(directory, uploadResults, filePaths)
-
-
expect(updated.entries).toHaveLength(1)
-
const fileNode = updated.entries[0].node
-
-
if ('blob' in fileNode) {
-
expect(fileNode.blob).toBeDefined()
-
expect(fileNode.blob.mimeType).toBe('text/html')
-
expect(fileNode.blob.size).toBe(100)
-
} else {
-
throw new Error('Expected file node')
-
}
-
})
-
-
test('should update files in nested directories', () => {
-
const directory: Directory = {
-
$type: 'place.wisp.fs#directory',
-
type: 'directory',
-
entries: [
-
{
-
name: 'css',
-
node: {
-
$type: 'place.wisp.fs#directory',
-
type: 'directory',
-
entries: [
-
{
-
name: 'styles.css',
-
node: {
-
$type: 'place.wisp.fs#file',
-
type: 'file',
-
blob: undefined as any,
-
},
-
},
-
],
-
},
-
},
-
],
-
}
-
-
const mockBlob = createMockBlobRef('text/css', 50)
-
const uploadResults: FileUploadResult[] = [
-
{
-
hash: TEST_CID_STRING,
-
blobRef: mockBlob,
-
mimeType: 'text/css',
-
encoding: 'gzip',
-
},
-
]
-
-
const filePaths = ['css/styles.css']
-
-
const updated = updateFileBlobs(directory, uploadResults, filePaths)
-
-
const cssDir = updated.entries[0]
-
expect(cssDir.name).toBe('css')
-
-
if ('entries' in cssDir.node) {
-
const cssFile = cssDir.node.entries[0]
-
expect(cssFile.name).toBe('styles.css')
-
-
if ('blob' in cssFile.node) {
-
expect(cssFile.node.blob.mimeType).toBe('text/css')
-
if ('encoding' in cssFile.node) {
-
expect(cssFile.node.encoding).toBe('gzip')
-
}
-
} else {
-
throw new Error('Expected file node')
-
}
-
} else {
-
throw new Error('Expected directory node')
-
}
-
})
-
-
test('should handle normalized paths with base folder removed', () => {
-
const directory: Directory = {
-
$type: 'place.wisp.fs#directory',
-
type: 'directory',
-
entries: [
-
{
-
name: 'index.html',
-
node: {
-
$type: 'place.wisp.fs#file',
-
type: 'file',
-
blob: undefined as any,
-
},
-
},
-
],
-
}
-
-
const mockBlob = createMockBlobRef('text/html', 100)
-
const uploadResults: FileUploadResult[] = [
-
{
-
hash: TEST_CID_STRING,
-
blobRef: mockBlob,
-
},
-
]
-
-
// Path includes base folder that should be normalized
-
const filePaths = ['dist/index.html']
-
-
const updated = updateFileBlobs(directory, uploadResults, filePaths)
-
-
const fileNode = updated.entries[0].node
-
if ('blob' in fileNode) {
-
expect(fileNode.blob).toBeDefined()
-
} else {
-
throw new Error('Expected file node')
-
}
-
})
-
-
test('should preserve file metadata (encoding, mimeType, base64)', () => {
-
const directory: Directory = {
-
$type: 'place.wisp.fs#directory',
-
type: 'directory',
-
entries: [
-
{
-
name: 'data.json',
-
node: {
-
$type: 'place.wisp.fs#file',
-
type: 'file',
-
blob: undefined as any,
-
},
-
},
-
],
-
}
-
-
const mockBlob = createMockBlobRef('application/json', 200)
-
const uploadResults: FileUploadResult[] = [
-
{
-
hash: TEST_CID_STRING,
-
blobRef: mockBlob,
-
mimeType: 'application/json',
-
encoding: 'gzip',
-
base64: true,
-
},
-
]
-
-
const filePaths = ['data.json']
-
-
const updated = updateFileBlobs(directory, uploadResults, filePaths)
-
-
const fileNode = updated.entries[0].node
-
if ('blob' in fileNode && 'mimeType' in fileNode && 'encoding' in fileNode && 'base64' in fileNode) {
-
expect(fileNode.mimeType).toBe('application/json')
-
expect(fileNode.encoding).toBe('gzip')
-
expect(fileNode.base64).toBe(true)
-
} else {
-
throw new Error('Expected file node with metadata')
-
}
-
})
-
-
test('should handle multiple files at different directory levels', () => {
-
const directory: Directory = {
-
$type: 'place.wisp.fs#directory',
-
type: 'directory',
-
entries: [
-
{
-
name: 'index.html',
-
node: {
-
$type: 'place.wisp.fs#file',
-
type: 'file',
-
blob: undefined as any,
-
},
-
},
-
{
-
name: 'assets',
-
node: {
-
$type: 'place.wisp.fs#directory',
-
type: 'directory',
-
entries: [
-
{
-
name: 'logo.svg',
-
node: {
-
$type: 'place.wisp.fs#file',
-
type: 'file',
-
blob: undefined as any,
-
},
-
},
-
],
-
},
-
},
-
],
-
}
-
-
const htmlBlob = createMockBlobRef('text/html', 100)
-
const svgBlob = createMockBlobRef('image/svg+xml', 500)
-
-
const uploadResults: FileUploadResult[] = [
-
{
-
hash: TEST_CID_STRING,
-
blobRef: htmlBlob,
-
},
-
{
-
hash: TEST_CID_STRING,
-
blobRef: svgBlob,
-
},
-
]
-
-
const filePaths = ['index.html', 'assets/logo.svg']
-
-
const updated = updateFileBlobs(directory, uploadResults, filePaths)
-
-
// Check root file
-
const indexNode = updated.entries[0].node
-
if ('blob' in indexNode) {
-
expect(indexNode.blob.mimeType).toBe('text/html')
-
}
-
-
// Check nested file
-
const assetsDir = updated.entries[1]
-
if ('entries' in assetsDir.node) {
-
const logoNode = assetsDir.node.entries[0].node
-
if ('blob' in logoNode) {
-
expect(logoNode.blob.mimeType).toBe('image/svg+xml')
-
}
-
}
-
})
-
})
-
-
describe('computeCID', () => {
-
test('should compute CID for gzipped+base64 encoded content', () => {
-
// This simulates the actual flow: gzip -> base64 -> compute CID
-
const originalContent = Buffer.from('Hello, World!')
-
const gzipped = compressFile(originalContent)
-
const base64Content = Buffer.from(gzipped.toString('base64'), 'binary')
-
-
const cid = computeCID(base64Content)
-
-
// CID should be a valid CIDv1 string starting with 'bafkrei'
-
expect(cid).toMatch(/^bafkrei[a-z0-9]+$/)
-
expect(cid.length).toBeGreaterThan(10)
-
})
-
-
test('should compute deterministic CIDs for identical content', () => {
-
const content = Buffer.from('Test content for CID calculation')
-
const gzipped = compressFile(content)
-
const base64Content = Buffer.from(gzipped.toString('base64'), 'binary')
-
-
const cid1 = computeCID(base64Content)
-
const cid2 = computeCID(base64Content)
-
-
expect(cid1).toBe(cid2)
-
})
-
-
test('should compute different CIDs for different content', () => {
-
const content1 = Buffer.from('Content A')
-
const content2 = Buffer.from('Content B')
-
-
const gzipped1 = compressFile(content1)
-
const gzipped2 = compressFile(content2)
-
-
const base64Content1 = Buffer.from(gzipped1.toString('base64'), 'binary')
-
const base64Content2 = Buffer.from(gzipped2.toString('base64'), 'binary')
-
-
const cid1 = computeCID(base64Content1)
-
const cid2 = computeCID(base64Content2)
-
-
expect(cid1).not.toBe(cid2)
-
})
-
-
test('should handle empty content', () => {
-
const emptyContent = Buffer.from('')
-
const gzipped = compressFile(emptyContent)
-
const base64Content = Buffer.from(gzipped.toString('base64'), 'binary')
-
-
const cid = computeCID(base64Content)
-
-
expect(cid).toMatch(/^bafkrei[a-z0-9]+$/)
-
})
-
-
test('should compute same CID as PDS for base64-encoded content', () => {
-
// Test that binary encoding produces correct bytes for CID calculation
-
const testContent = Buffer.from('<!DOCTYPE html><html><body>Hello</body></html>')
-
const gzipped = compressFile(testContent)
-
const base64Content = Buffer.from(gzipped.toString('base64'), 'binary')
-
-
// Compute CID twice to ensure consistency
-
const cid1 = computeCID(base64Content)
-
const cid2 = computeCID(base64Content)
-
-
expect(cid1).toBe(cid2)
-
expect(cid1).toMatch(/^bafkrei/)
-
})
-
-
test('should use binary encoding for base64 strings', () => {
-
// This test verifies we're using the correct encoding method
-
// For base64 strings, 'binary' encoding ensures each character becomes exactly one byte
-
const content = Buffer.from('Test content')
-
const gzipped = compressFile(content)
-
const base64String = gzipped.toString('base64')
-
-
// Using binary encoding (what we use in production)
-
const base64Content = Buffer.from(base64String, 'binary')
-
-
// Verify the length matches the base64 string length
-
expect(base64Content.length).toBe(base64String.length)
-
-
// Verify CID is computed correctly
-
const cid = computeCID(base64Content)
-
expect(cid).toMatch(/^bafkrei/)
-
})
-
})
-
-
describe('extractBlobMap', () => {
-
test('should extract blob map from flat directory structure', () => {
-
const mockCid = CID.parse(TEST_CID_STRING)
-
const mockBlob = new BlobRef(mockCid as any, 'text/html', 100)
-
-
const directory: Directory = {
-
$type: 'place.wisp.fs#directory',
-
type: 'directory',
-
entries: [
-
{
-
name: 'index.html',
-
node: {
-
$type: 'place.wisp.fs#file',
-
type: 'file',
-
blob: mockBlob,
-
},
-
},
-
],
-
}
-
-
const blobMap = extractBlobMap(directory)
-
-
expect(blobMap.size).toBe(1)
-
expect(blobMap.has('index.html')).toBe(true)
-
-
const entry = blobMap.get('index.html')
-
expect(entry?.cid).toBe(TEST_CID_STRING)
-
expect(entry?.blobRef).toBe(mockBlob)
-
})
-
-
test('should extract blob map from nested directory structure', () => {
-
const mockCid1 = CID.parse(TEST_CID_STRING)
-
const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi')
-
-
const mockBlob1 = new BlobRef(mockCid1 as any, 'text/html', 100)
-
const mockBlob2 = new BlobRef(mockCid2 as any, 'text/css', 50)
-
-
const directory: Directory = {
-
$type: 'place.wisp.fs#directory',
-
type: 'directory',
-
entries: [
-
{
-
name: 'index.html',
-
node: {
-
$type: 'place.wisp.fs#file',
-
type: 'file',
-
blob: mockBlob1,
-
},
-
},
-
{
-
name: 'assets',
-
node: {
-
$type: 'place.wisp.fs#directory',
-
type: 'directory',
-
entries: [
-
{
-
name: 'styles.css',
-
node: {
-
$type: 'place.wisp.fs#file',
-
type: 'file',
-
blob: mockBlob2,
-
},
-
},
-
],
-
},
-
},
-
],
-
}
-
-
const blobMap = extractBlobMap(directory)
-
-
expect(blobMap.size).toBe(2)
-
expect(blobMap.has('index.html')).toBe(true)
-
expect(blobMap.has('assets/styles.css')).toBe(true)
-
-
expect(blobMap.get('index.html')?.cid).toBe(TEST_CID_STRING)
-
expect(blobMap.get('assets/styles.css')?.cid).toBe('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi')
-
})
-
-
test('should handle deeply nested directory structures', () => {
-
const mockCid = CID.parse(TEST_CID_STRING)
-
const mockBlob = new BlobRef(mockCid as any, 'text/javascript', 200)
-
-
const directory: Directory = {
-
$type: 'place.wisp.fs#directory',
-
type: 'directory',
-
entries: [
-
{
-
name: 'src',
-
node: {
-
$type: 'place.wisp.fs#directory',
-
type: 'directory',
-
entries: [
-
{
-
name: 'lib',
-
node: {
-
$type: 'place.wisp.fs#directory',
-
type: 'directory',
-
entries: [
-
{
-
name: 'utils.js',
-
node: {
-
$type: 'place.wisp.fs#file',
-
type: 'file',
-
blob: mockBlob,
-
},
-
},
-
],
-
},
-
},
-
],
-
},
-
},
-
],
-
}
-
-
const blobMap = extractBlobMap(directory)
-
-
expect(blobMap.size).toBe(1)
-
expect(blobMap.has('src/lib/utils.js')).toBe(true)
-
expect(blobMap.get('src/lib/utils.js')?.cid).toBe(TEST_CID_STRING)
-
})
-
-
test('should handle empty directory', () => {
-
const directory: Directory = {
-
$type: 'place.wisp.fs#directory',
-
type: 'directory',
-
entries: [],
-
}
-
-
const blobMap = extractBlobMap(directory)
-
-
expect(blobMap.size).toBe(0)
-
})
-
-
test('should correctly extract CID from BlobRef instances (not plain objects)', () => {
-
// This test verifies the fix: AT Protocol SDK returns BlobRef instances,
-
// not plain objects with $type and $link properties
-
const mockCid = CID.parse(TEST_CID_STRING)
-
const mockBlob = new BlobRef(mockCid as any, 'application/octet-stream', 500)
-
-
const directory: Directory = {
-
$type: 'place.wisp.fs#directory',
-
type: 'directory',
-
entries: [
-
{
-
name: 'test.bin',
-
node: {
-
$type: 'place.wisp.fs#file',
-
type: 'file',
-
blob: mockBlob,
-
},
-
},
-
],
-
}
-
-
const blobMap = extractBlobMap(directory)
-
-
// The fix: we call .toString() on the CID instance instead of accessing $link
-
expect(blobMap.get('test.bin')?.cid).toBe(TEST_CID_STRING)
-
expect(blobMap.get('test.bin')?.blobRef.ref.toString()).toBe(TEST_CID_STRING)
-
})
-
-
test('should handle multiple files in same directory', () => {
-
const mockCid1 = CID.parse(TEST_CID_STRING)
-
const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi')
-
const mockCid3 = CID.parse('bafkreieb3ixgchss44kw7xiavnkns47emdfsqbhcdfluo3p6n3o53fl3vq')
-
-
const mockBlob1 = new BlobRef(mockCid1 as any, 'image/png', 1000)
-
const mockBlob2 = new BlobRef(mockCid2 as any, 'image/png', 2000)
-
const mockBlob3 = new BlobRef(mockCid3 as any, 'image/png', 3000)
-
-
const directory: Directory = {
-
$type: 'place.wisp.fs#directory',
-
type: 'directory',
-
entries: [
-
{
-
name: 'images',
-
node: {
-
$type: 'place.wisp.fs#directory',
-
type: 'directory',
-
entries: [
-
{
-
name: 'logo.png',
-
node: {
-
$type: 'place.wisp.fs#file',
-
type: 'file',
-
blob: mockBlob1,
-
},
-
},
-
{
-
name: 'banner.png',
-
node: {
-
$type: 'place.wisp.fs#file',
-
type: 'file',
-
blob: mockBlob2,
-
},
-
},
-
{
-
name: 'icon.png',
-
node: {
-
$type: 'place.wisp.fs#file',
-
type: 'file',
-
blob: mockBlob3,
-
},
-
},
-
],
-
},
-
},
-
],
-
}
-
-
const blobMap = extractBlobMap(directory)
-
-
expect(blobMap.size).toBe(3)
-
expect(blobMap.has('images/logo.png')).toBe(true)
-
expect(blobMap.has('images/banner.png')).toBe(true)
-
expect(blobMap.has('images/icon.png')).toBe(true)
-
})
-
-
test('should handle mixed directory and file structure', () => {
-
const mockCid1 = CID.parse(TEST_CID_STRING)
-
const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi')
-
const mockCid3 = CID.parse('bafkreieb3ixgchss44kw7xiavnkns47emdfsqbhcdfluo3p6n3o53fl3vq')
-
-
const directory: Directory = {
-
$type: 'place.wisp.fs#directory',
-
type: 'directory',
-
entries: [
-
{
-
name: 'index.html',
-
node: {
-
$type: 'place.wisp.fs#file',
-
type: 'file',
-
blob: new BlobRef(mockCid1 as any, 'text/html', 100),
-
},
-
},
-
{
-
name: 'assets',
-
node: {
-
$type: 'place.wisp.fs#directory',
-
type: 'directory',
-
entries: [
-
{
-
name: 'styles.css',
-
node: {
-
$type: 'place.wisp.fs#file',
-
type: 'file',
-
blob: new BlobRef(mockCid2 as any, 'text/css', 50),
-
},
-
},
-
],
-
},
-
},
-
{
-
name: 'README.md',
-
node: {
-
$type: 'place.wisp.fs#file',
-
type: 'file',
-
blob: new BlobRef(mockCid3 as any, 'text/markdown', 200),
-
},
-
},
-
],
-
}
-
-
const blobMap = extractBlobMap(directory)
-
-
expect(blobMap.size).toBe(3)
-
expect(blobMap.has('index.html')).toBe(true)
-
expect(blobMap.has('assets/styles.css')).toBe(true)
-
expect(blobMap.has('README.md')).toBe(true)
-
})
-
})
···
-470
src/lib/wisp-utils.ts
···
-
import type { BlobRef } from "@atproto/api";
-
import type { Record, Directory, File, Entry } from "../lexicons/types/place/wisp/fs";
-
import { validateRecord } from "../lexicons/types/place/wisp/fs";
-
import { gzipSync } from 'zlib';
-
import { CID } from 'multiformats/cid';
-
import { sha256 } from 'multiformats/hashes/sha2';
-
import * as raw from 'multiformats/codecs/raw';
-
import { createHash } from 'crypto';
-
import * as mf from 'multiformats';
-
-
export interface UploadedFile {
-
name: string;
-
content: Buffer;
-
mimeType: string;
-
size: number;
-
compressed?: boolean;
-
base64Encoded?: boolean;
-
originalMimeType?: string;
-
}
-
-
export interface FileUploadResult {
-
hash: string;
-
blobRef: BlobRef;
-
encoding?: 'gzip';
-
mimeType?: string;
-
base64?: boolean;
-
}
-
-
export interface ProcessedDirectory {
-
directory: Directory;
-
fileCount: number;
-
}
-
-
/**
-
* Determine if a file should be gzip compressed based on its MIME type and filename
-
*/
-
export function shouldCompressFile(mimeType: string, fileName?: string): boolean {
-
// Never compress _redirects file - it needs to be plain text for the hosting service
-
if (fileName && (fileName.endsWith('/_redirects') || fileName === '_redirects')) {
-
return false;
-
}
-
-
// Compress text-based files and uncompressed audio formats
-
const compressibleTypes = [
-
'text/html',
-
'text/css',
-
'text/javascript',
-
'application/javascript',
-
'application/json',
-
'image/svg+xml',
-
'text/xml',
-
'application/xml',
-
'text/plain',
-
'application/x-javascript',
-
// Uncompressed audio formats (WAV, AIFF, etc.)
-
'audio/wav',
-
'audio/wave',
-
'audio/x-wav',
-
'audio/aiff',
-
'audio/x-aiff'
-
];
-
-
// Check if mime type starts with any compressible type
-
return compressibleTypes.some(type => mimeType.startsWith(type));
-
}
-
-
/**
-
* Compress a file using gzip with deterministic output
-
*/
-
export function compressFile(content: Buffer): Buffer {
-
return gzipSync(content, {
-
level: 9
-
});
-
}
-
-
/**
-
* Process uploaded files into a directory structure
-
*/
-
export function processUploadedFiles(files: UploadedFile[]): ProcessedDirectory {
-
const entries: Entry[] = [];
-
let fileCount = 0;
-
-
// Group files by directory
-
const directoryMap = new Map<string, UploadedFile[]>();
-
-
for (const file of files) {
-
// Skip undefined/null files (defensive)
-
if (!file || !file.name) {
-
console.error('Skipping undefined or invalid file in processUploadedFiles');
-
continue;
-
}
-
-
// Remove any base folder name from the path
-
const normalizedPath = file.name.replace(/^[^\/]*\//, '');
-
-
// Skip files in .git directories
-
if (normalizedPath.startsWith('.git/') || normalizedPath === '.git') {
-
continue;
-
}
-
-
const parts = normalizedPath.split('/');
-
-
if (parts.length === 1) {
-
// Root level file
-
entries.push({
-
name: parts[0],
-
node: {
-
$type: 'place.wisp.fs#file' as const,
-
type: 'file' as const,
-
blob: undefined as any // Will be filled after upload
-
}
-
});
-
fileCount++;
-
} else {
-
// File in subdirectory
-
const dirPath = parts.slice(0, -1).join('/');
-
if (!directoryMap.has(dirPath)) {
-
directoryMap.set(dirPath, []);
-
}
-
directoryMap.get(dirPath)!.push({
-
...file,
-
name: normalizedPath
-
});
-
}
-
}
-
-
// Process subdirectories
-
for (const [dirPath, dirFiles] of directoryMap) {
-
const dirEntries: Entry[] = [];
-
-
for (const file of dirFiles) {
-
const fileName = file.name.split('/').pop()!;
-
dirEntries.push({
-
name: fileName,
-
node: {
-
$type: 'place.wisp.fs#file' as const,
-
type: 'file' as const,
-
blob: undefined as any // Will be filled after upload
-
}
-
});
-
fileCount++;
-
}
-
-
// Build nested directory structure
-
const pathParts = dirPath.split('/');
-
let currentEntries = entries;
-
-
for (let i = 0; i < pathParts.length; i++) {
-
const part = pathParts[i];
-
const isLast = i === pathParts.length - 1;
-
-
let existingEntry = currentEntries.find(e => e.name === part);
-
-
if (!existingEntry) {
-
const newDir = {
-
$type: 'place.wisp.fs#directory' as const,
-
type: 'directory' as const,
-
entries: isLast ? dirEntries : []
-
};
-
-
existingEntry = {
-
name: part,
-
node: newDir
-
};
-
currentEntries.push(existingEntry);
-
} else if ('entries' in existingEntry.node && isLast) {
-
(existingEntry.node as any).entries.push(...dirEntries);
-
}
-
-
if (existingEntry && 'entries' in existingEntry.node) {
-
currentEntries = (existingEntry.node as any).entries;
-
}
-
}
-
}
-
-
const result = {
-
directory: {
-
$type: 'place.wisp.fs#directory' as const,
-
type: 'directory' as const,
-
entries
-
},
-
fileCount
-
};
-
-
return result;
-
}
-
-
/**
-
* Create the manifest record for a site
-
*/
-
export function createManifest(
-
siteName: string,
-
root: Directory,
-
fileCount: number
-
): Record {
-
const manifest = {
-
$type: 'place.wisp.fs' as const,
-
site: siteName,
-
root,
-
fileCount,
-
createdAt: new Date().toISOString()
-
};
-
-
// Validate the manifest before returning
-
const validationResult = validateRecord(manifest);
-
if (!validationResult.success) {
-
throw new Error(`Invalid manifest: ${validationResult.error?.message || 'Validation failed'}`);
-
}
-
-
return manifest;
-
}
-
-
/**
-
* Update file blobs in directory structure after upload
-
* Uses path-based matching to correctly match files in nested directories
-
* Filters out files that were not successfully uploaded
-
*/
-
export function updateFileBlobs(
-
directory: Directory,
-
uploadResults: FileUploadResult[],
-
filePaths: string[],
-
currentPath: string = '',
-
successfulPaths?: Set<string>
-
): Directory {
-
const updatedEntries = directory.entries.map(entry => {
-
if ('type' in entry.node && entry.node.type === 'file') {
-
// Build the full path for this file
-
const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
-
-
// If successfulPaths is provided, skip files that weren't successfully uploaded
-
if (successfulPaths && !successfulPaths.has(fullPath)) {
-
return null; // Filter out failed files
-
}
-
-
// Find exact match in filePaths (need to handle normalized paths)
-
const fileIndex = filePaths.findIndex((path) => {
-
// Normalize both paths by removing leading base folder
-
const normalizedUploadPath = path.replace(/^[^\/]*\//, '');
-
const normalizedEntryPath = fullPath;
-
return normalizedUploadPath === normalizedEntryPath || path === fullPath;
-
});
-
-
if (fileIndex !== -1 && uploadResults[fileIndex]) {
-
const result = uploadResults[fileIndex];
-
const blobRef = result.blobRef;
-
-
return {
-
...entry,
-
node: {
-
$type: 'place.wisp.fs#file' as const,
-
type: 'file' as const,
-
blob: blobRef,
-
...(result.encoding && { encoding: result.encoding }),
-
...(result.mimeType && { mimeType: result.mimeType }),
-
...(result.base64 && { base64: result.base64 })
-
}
-
};
-
} else {
-
console.error(`Could not find blob for file: ${fullPath}`);
-
return null; // Filter out files without blobs
-
}
-
} else if ('type' in entry.node && entry.node.type === 'directory') {
-
const dirPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
-
return {
-
...entry,
-
node: updateFileBlobs(entry.node as Directory, uploadResults, filePaths, dirPath, successfulPaths)
-
};
-
}
-
return entry;
-
}).filter(entry => entry !== null) as Entry[]; // Remove null entries (failed files)
-
-
const result = {
-
$type: 'place.wisp.fs#directory' as const,
-
type: 'directory' as const,
-
entries: updatedEntries
-
};
-
-
return result;
-
}
-
-
/**
-
* Compute CID (Content Identifier) for blob content
-
* Uses the same algorithm as AT Protocol: CIDv1 with raw codec and SHA-256
-
* Based on @atproto/common/src/ipld.ts sha256RawToCid implementation
-
*/
-
export function computeCID(content: Buffer): string {
-
// Use node crypto to compute sha256 hash (same as AT Protocol)
-
const hash = createHash('sha256').update(content).digest();
-
// Create digest object from hash bytes
-
const digest = mf.digest.create(sha256.code, hash);
-
// Create CIDv1 with raw codec
-
const cid = CID.createV1(raw.code, digest);
-
return cid.toString();
-
}
-
-
/**
-
* Extract blob information from a directory tree
-
* Returns a map of file paths to their blob refs and CIDs
-
*/
-
export function extractBlobMap(
-
directory: Directory,
-
currentPath: string = ''
-
): Map<string, { blobRef: BlobRef; cid: string }> {
-
const blobMap = new Map<string, { blobRef: BlobRef; cid: string }>();
-
-
for (const entry of directory.entries) {
-
const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
-
-
if ('type' in entry.node && entry.node.type === 'file') {
-
const fileNode = entry.node as File;
-
// AT Protocol SDK returns BlobRef class instances, not plain objects
-
// The ref is a CID instance that can be converted to string
-
if (fileNode.blob && fileNode.blob.ref) {
-
const cidString = fileNode.blob.ref.toString();
-
blobMap.set(fullPath, {
-
blobRef: fileNode.blob,
-
cid: cidString
-
});
-
}
-
} else if ('type' in entry.node && entry.node.type === 'directory') {
-
const subMap = extractBlobMap(entry.node as Directory, fullPath);
-
subMap.forEach((value, key) => blobMap.set(key, value));
-
}
-
// Skip subfs nodes - they don't contain blobs in the main tree
-
}
-
-
return blobMap;
-
}
-
-
/**
-
* Extract all subfs URIs from a directory tree with their mount paths
-
*/
-
export function extractSubfsUris(
-
directory: Directory,
-
currentPath: string = ''
-
): Array<{ uri: string; path: string }> {
-
const uris: Array<{ uri: string; path: string }> = [];
-
-
for (const entry of directory.entries) {
-
const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
-
-
if ('type' in entry.node) {
-
if (entry.node.type === 'subfs') {
-
// Subfs node with subject URI
-
const subfsNode = entry.node as any;
-
if (subfsNode.subject) {
-
uris.push({ uri: subfsNode.subject, path: fullPath });
-
}
-
} else if (entry.node.type === 'directory') {
-
// Recursively search subdirectories
-
const subUris = extractSubfsUris(entry.node as Directory, fullPath);
-
uris.push(...subUris);
-
}
-
}
-
}
-
-
return uris;
-
}
-
-
/**
-
* Estimate the JSON size of a directory tree
-
*/
-
export function estimateDirectorySize(directory: Directory): number {
-
return JSON.stringify(directory).length;
-
}
-
-
/**
-
* Count files in a directory tree
-
*/
-
export function countFilesInDirectory(directory: Directory): number {
-
let count = 0;
-
for (const entry of directory.entries) {
-
if ('type' in entry.node && entry.node.type === 'file') {
-
count++;
-
} else if ('type' in entry.node && entry.node.type === 'directory') {
-
count += countFilesInDirectory(entry.node as Directory);
-
}
-
}
-
return count;
-
}
-
-
/**
-
* Find all directories in a tree with their paths and sizes
-
*/
-
export function findLargeDirectories(directory: Directory, currentPath: string = ''): Array<{
-
path: string;
-
directory: Directory;
-
size: number;
-
fileCount: number;
-
}> {
-
const result: Array<{ path: string; directory: Directory; size: number; fileCount: number }> = [];
-
-
for (const entry of directory.entries) {
-
if ('type' in entry.node && entry.node.type === 'directory') {
-
const dirPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
-
const dir = entry.node as Directory;
-
const size = estimateDirectorySize(dir);
-
const fileCount = countFilesInDirectory(dir);
-
-
result.push({ path: dirPath, directory: dir, size, fileCount });
-
-
// Recursively find subdirectories
-
const subdirs = findLargeDirectories(dir, dirPath);
-
result.push(...subdirs);
-
}
-
}
-
-
return result;
-
}
-
-
/**
-
* Replace a directory with a subfs node in the tree
-
*/
-
export function replaceDirectoryWithSubfs(
-
directory: Directory,
-
targetPath: string,
-
subfsUri: string
-
): Directory {
-
const pathParts = targetPath.split('/');
-
const targetName = pathParts[pathParts.length - 1];
-
const parentPath = pathParts.slice(0, -1).join('/');
-
-
// If this is a root-level directory
-
if (pathParts.length === 1) {
-
const newEntries = directory.entries.map(entry => {
-
if (entry.name === targetName && 'type' in entry.node && entry.node.type === 'directory') {
-
return {
-
name: entry.name,
-
node: {
-
$type: 'place.wisp.fs#subfs' as const,
-
type: 'subfs' as const,
-
subject: subfsUri,
-
flat: false // Preserve directory structure
-
}
-
};
-
}
-
return entry;
-
});
-
-
return {
-
$type: 'place.wisp.fs#directory' as const,
-
type: 'directory' as const,
-
entries: newEntries
-
};
-
}
-
-
// Recursively navigate to parent directory
-
const newEntries = directory.entries.map(entry => {
-
if ('type' in entry.node && entry.node.type === 'directory') {
-
const entryPath = entry.name;
-
if (parentPath.startsWith(entryPath) || parentPath === entry.name) {
-
const remainingPath = pathParts.slice(1).join('/');
-
return {
-
name: entry.name,
-
node: {
-
...replaceDirectoryWithSubfs(entry.node as Directory, remainingPath, subfsUri),
-
$type: 'place.wisp.fs#directory' as const
-
}
-
};
-
}
-
}
-
return entry;
-
});
-
-
return {
-
$type: 'place.wisp.fs#directory' as const,
-
type: 'directory' as const,
-
entries: newEntries
-
};
-
}
···
+1 -1
src/routes/admin.ts apps/main-app/src/routes/admin.ts
···
// Admin API routes
import { Elysia, t } from 'elysia'
import { adminAuth, requireAdmin } from '../lib/admin-auth'
-
import { logCollector, errorTracker, metricsCollector } from '../lib/observability'
import { db } from '../lib/db'
export const adminRoutes = (cookieSecret: string) =>
···
// Admin API routes
import { Elysia, t } from 'elysia'
import { adminAuth, requireAdmin } from '../lib/admin-auth'
+
import { logCollector, errorTracker, metricsCollector } from '@wisp/observability'
import { db } from '../lib/db'
export const adminRoutes = (cookieSecret: string) =>
+3 -1
src/routes/auth.ts apps/main-app/src/routes/auth.ts
···
import { getSitesByDid, getDomainByDid, getCookieSecret } from '../lib/db'
import { syncSitesFromPDS } from '../lib/sync-sites'
import { authenticateRequest } from '../lib/wisp-auth'
-
import { logger } from '../lib/observability'
export const authRoutes = (client: NodeOAuthClient, cookieSecret: string) => new Elysia({
cookie: {
···
import { getSitesByDid, getDomainByDid, getCookieSecret } from '../lib/db'
import { syncSitesFromPDS } from '../lib/sync-sites'
import { authenticateRequest } from '../lib/wisp-auth'
+
import { createLogger } from '@wisp/observability'
+
+
const logger = createLogger('main-app')
export const authRoutes = (client: NodeOAuthClient, cookieSecret: string) => new Elysia({
cookie: {
+3 -1
src/routes/domain.ts apps/main-app/src/routes/domain.ts
···
} from '../lib/db'
import { createHash } from 'crypto'
import { verifyCustomDomain } from '../lib/dns-verify'
-
import { logger } from '../lib/logger'
export const domainRoutes = (client: NodeOAuthClient, cookieSecret: string) =>
new Elysia({
···
} from '../lib/db'
import { createHash } from 'crypto'
import { verifyCustomDomain } from '../lib/dns-verify'
+
import { createLogger } from '@wisp/observability'
+
+
const logger = createLogger('main-app')
export const domainRoutes = (client: NodeOAuthClient, cookieSecret: string) =>
new Elysia({
+4 -2
src/routes/site.ts apps/main-app/src/routes/site.ts
···
import { NodeOAuthClient } from '@atproto/oauth-client-node'
import { Agent } from '@atproto/api'
import { deleteSite } from '../lib/db'
-
import { logger } from '../lib/logger'
-
import { extractSubfsUris } from '../lib/wisp-utils'
export const siteRoutes = (client: NodeOAuthClient, cookieSecret: string) =>
new Elysia({
···
import { NodeOAuthClient } from '@atproto/oauth-client-node'
import { Agent } from '@atproto/api'
import { deleteSite } from '../lib/db'
+
import { createLogger } from '@wisp/observability'
+
import { extractSubfsUris } from '@wisp/atproto-utils'
+
+
const logger = createLogger('main-app')
export const siteRoutes = (client: NodeOAuthClient, cookieSecret: string) =>
new Elysia({
+4 -2
src/routes/user.ts apps/main-app/src/routes/user.ts
···
import { Agent } from '@atproto/api'
import { getSitesByDid, getDomainByDid, getCustomDomainsByDid, getWispDomainInfo, getDomainsBySite, getAllWispDomains } from '../lib/db'
import { syncSitesFromPDS } from '../lib/sync-sites'
-
import { logger } from '../lib/logger'
export const userRoutes = (client: NodeOAuthClient, cookieSecret: string) =>
new Elysia({
···
})
.post('/sync', async ({ auth }) => {
try {
-
logger.debug('[User] Manual sync requested for', auth.did)
const result = await syncSitesFromPDS(auth.did, auth.session)
return {
···
import { Agent } from '@atproto/api'
import { getSitesByDid, getDomainByDid, getCustomDomainsByDid, getWispDomainInfo, getDomainsBySite, getAllWispDomains } from '../lib/db'
import { syncSitesFromPDS } from '../lib/sync-sites'
+
import { createLogger } from '@wisp/observability'
+
+
const logger = createLogger('main-app')
export const userRoutes = (client: NodeOAuthClient, cookieSecret: string) =>
new Elysia({
···
})
.post('/sync', async ({ auth }) => {
try {
+
logger.debug('[User] Manual sync requested for', { did: auth.did })
const result = await syncSitesFromPDS(auth.did, auth.session)
return {
+14 -10
src/routes/wisp.ts apps/main-app/src/routes/wisp.ts
···
type UploadedFile,
type FileUploadResult,
processUploadedFiles,
-
createManifest,
updateFileBlobs,
shouldCompressFile,
compressFile,
computeCID,
extractBlobMap,
-
extractSubfsUris,
-
findLargeDirectories,
-
replaceDirectoryWithSubfs,
-
estimateDirectorySize
-
} from '../lib/wisp-utils'
import { upsertSite } from '../lib/db'
-
import { logger } from '../lib/observability'
-
import { validateRecord } from '../lexicons/types/place/wisp/fs'
-
import { validateRecord as validateSubfsRecord } from '../lexicons/types/place/wisp/subfs'
-
import { MAX_SITE_SIZE, MAX_FILE_SIZE, MAX_FILE_COUNT } from '../lib/constants'
import {
createUploadJob,
getUploadJob,
···
failUploadJob,
addJobListener
} from '../lib/upload-jobs'
function isValidSiteName(siteName: string): boolean {
if (!siteName || typeof siteName !== 'string') return false;
···
type UploadedFile,
type FileUploadResult,
processUploadedFiles,
updateFileBlobs,
+
findLargeDirectories,
+
replaceDirectoryWithSubfs,
+
estimateDirectorySize
+
} from '@wisp/fs-utils'
+
import {
shouldCompressFile,
compressFile,
computeCID,
extractBlobMap,
+
extractSubfsUris
+
} from '@wisp/atproto-utils'
+
import { createManifest } from '@wisp/fs-utils'
import { upsertSite } from '../lib/db'
+
import { createLogger } from '@wisp/observability'
+
import { validateRecord, type Directory } from '@wisp/lexicons/types/place/wisp/fs'
+
import { validateRecord as validateSubfsRecord } from '@wisp/lexicons/types/place/wisp/subfs'
+
import { MAX_SITE_SIZE, MAX_FILE_SIZE, MAX_FILE_COUNT } from '@wisp/constants'
import {
createUploadJob,
getUploadJob,
···
failUploadJob,
addJobListener
} from '../lib/upload-jobs'
+
+
const logger = createLogger('main-app')
function isValidSiteName(siteName: string): boolean {
if (!siteName || typeof siteName !== 'string') return false;
-40
testDeploy/index.html
···
-
<!DOCTYPE html>
-
<html lang="en">
-
<head>
-
<meta charset="UTF-8">
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
-
<title>Wisp.place Test Site</title>
-
<style>
-
body {
-
font-family: system-ui, -apple-system, sans-serif;
-
max-width: 800px;
-
margin: 4rem auto;
-
padding: 0 2rem;
-
line-height: 1.6;
-
}
-
h1 {
-
color: #333;
-
}
-
.info {
-
background: #f0f0f0;
-
padding: 1rem;
-
border-radius: 8px;
-
margin: 2rem 0;
-
}
-
</style>
-
</head>
-
<body>
-
<h1>Hello from Wisp.place!</h1>
-
<p>This is a test deployment using the wisp-cli and Tangled Spindles CI/CD.</p>
-
-
<div class="info">
-
<h2>About this deployment</h2>
-
<p>This site was deployed to the AT Protocol using:</p>
-
<ul>
-
<li>Wisp.place CLI (Rust)</li>
-
<li>Tangled Spindles CI/CD</li>
-
<li>AT Protocol for decentralized hosting</li>
-
</ul>
-
</div>
-
</body>
-
</html>
···
+1 -1
tsconfig.json
···
/* Modules */
"module": "ES2022" /* Specify what module code is generated. */,
// "rootDir": "./", /* Specify the root folder within your source files. */
-
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
···
/* Modules */
"module": "ES2022" /* Specify what module code is generated. */,
// "rootDir": "./", /* Specify the root folder within your source files. */
+
"moduleResolution": "bundler" /* Specify how TypeScript looks up a file from a given module specifier. */,
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */