Something went wrong. Try again.
Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
Something went wrong. Try again.
14 kB · 386 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387/** * Main server entry point for the hosting service * Handles routing and request dispatching */
import { normalizeSitePath } from '@wispplace/fs-utils'import { createLogger } from '@wispplace/observability'import { observabilityErrorHandler, observabilityMiddleware } from '@wispplace/observability/middleware/hono'import { siteIdFromHostname } from '@wispplace/private-sites'import { type Context, Hono } from 'hono'import { cors } from 'hono/cors'import { type CacheInvalidationHealthSnapshot, getCacheInvalidationHealthSnapshot, isCacheInvalidationOverloaded,} from './lib/cache-invalidation'import { cache } from './lib/cache-manager'import { getCustomDomain, getCustomDomainByHash, getWispDomain } from './lib/db'import { serveFromCache, serveFromCacheWithRewrite } from './lib/file-serving'import { privateNotFound, servePrivateSite } from './lib/private-serving'import { decodeRequestPathname, extractHeaders, isValidRkey } from './lib/request-utils'import { recordSiteResponse } from './lib/site-metrics'import { getStorageReadHealthSnapshot, type StorageReadHealthSnapshot } from './lib/storage'import { isValidAtprotoIdentifier, resolveDid } from './lib/utils'
const logger = createLogger('hosting-service')
function recordPublicSiteResponse(ownerDid: string, siteRkey: string, method: string, response: Response): void { recordSiteResponse(ownerDid, siteRkey, method, response.status, response.headers.get('content-type'))}
function recordPublicSiteFailure(ownerDid: string, siteRkey: string, method: string): void { recordSiteResponse(ownerDid, siteRkey, method, 500, null)}
function trackPublicSiteResponse( ownerDid: string, siteRkey: string, method: string, responsePromise: Promise<Response>,): Promise<Response> { return responsePromise .then((response) => { recordPublicSiteResponse(ownerDid, siteRkey, method, response) return response }) .then( (response) => response, (error) => { recordPublicSiteFailure(ownerDid, siteRkey, method) throw error }, )}
async function resolveDidCached(identifier: string): Promise<string | null> { if (!isValidAtprotoIdentifier(identifier)) return null return cache.getOrFetch('handles', identifier, () => resolveDid(identifier), { cacheIf: (value) => value !== null, })}
type PublicDomainMapping = { did: string rkey: string | null}
const DEFAULT_BASE_HOST = 'wisp.place'const HOSTNAME_PATTERN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/
export function normalizeConfiguredHostname(value: string | undefined, fallback: string): string { const configured = value?.trim() if (!configured || /[/?#@]/.test(configured)) return fallback
try { const url = new URL(`http://${configured}`) if (url.username || url.password || url.pathname !== '/' || url.search || url.hash) return fallback
const hostname = url.hostname.toLowerCase().replace(/\.$/, '') return HOSTNAME_PATTERN.test(hostname) ? hostname : fallback } catch { return fallback }}
const BASE_HOST = normalizeConfiguredHostname(process.env.BASE_HOST, DEFAULT_BASE_HOST)
// Separate origins keep tenant JavaScript and ambient cookies isolated.const PRIVATE_HOST = normalizeConfiguredHostname(process.env.PRIVATE_HOST, `priv.${BASE_HOST}`)
async function serveMappedPublicDomain( c: Context, domain: PublicDomainMapping | null, path: string, notFoundMessage: string,): Promise<Response> { if (!domain) { return c.text(notFoundMessage, 404) }
if (!domain.rkey) { return c.text('Domain not mapped to a site', 404) }
if (!isValidRkey(domain.rkey)) { return c.text('Invalid site configuration', 500) }
const headers = extractHeaders(c.req.raw.headers) return trackPublicSiteResponse( domain.did, domain.rkey, c.req.method, serveFromCache(domain.did, domain.rkey, path, c.req.url, headers, { method: c.req.method, signal: c.req.raw.signal, }), )}
const app = new Hono()
// Never allow cross-origin credentialed reads of private content.app.use( '*', cors({ origin: '*', allowMethods: ['GET', 'HEAD', 'OPTIONS'], allowHeaders: ['Content-Type', 'Authorization'], exposeHeaders: [ 'Content-Length', 'Content-Type', 'Content-Encoding', 'Content-Range', 'Accept-Ranges', 'Cache-Control', 'ETag', ], maxAge: 86400, // 24 hours credentials: false, }),)
// Add observability middlewareapp.use('*', observabilityMiddleware('hosting-service'))
// An unreplayable invalidation was lost: do not serve potentially stale tenant content.app.use('*', async (c, next) => { if (c.req.path !== '/health' && c.req.path !== '/live' && isCacheInvalidationOverloaded()) { return c.text('Hosting temporarily unavailable', 503, { 'Cache-Control': 'no-store' }) } await next()})
// Error handlerapp.onError(observabilityErrorHandler('hosting-service'))
/** * Build the constant-time hosting health response without issuing disk or S3 I/O. * * @param snapshot Cache-invalidation connection state * @param configured Whether Redis invalidation is configured * @param storageSnapshot Safe public cold-tier read-health state * @returns Compact liveness/readiness state */export function getCacheInvalidationHealthResponse( snapshot: CacheInvalidationHealthSnapshot, configured: boolean, storageSnapshot: StorageReadHealthSnapshot = getStorageReadHealthSnapshot(),) { const replayHealthy = snapshot.replayConnected && snapshot.replayState === 'healthy' && !snapshot.overloadLatched // Fail closed: the live pub/sub subscriber and the replay tail must both be // attached before invalidation can be called healthy. Reporting 'ok' with a // dead subscriber hides live-path outages behind replay's fallback. const subscriberHealthy = snapshot.subscriberConnected const storageHealthy = storageSnapshot.status === 'healthy' || storageSnapshot.status === 'not-configured'
return { status: !snapshot.overloadLatched && (!configured || (subscriberHealthy && replayHealthy)) && storageHealthy ? 'ok' : 'degraded', cacheInvalidation: { configured, subscriberConnected: snapshot.subscriberConnected, replayConnected: snapshot.replayConnected, replayState: snapshot.replayState, retrying: snapshot.retrying, gapCount: snapshot.gapCount, lastGapAt: snapshot.lastGapAt, lastGapRecoveryAt: snapshot.lastGapRecoveryAt, subscriberRecreations: snapshot.subscriberRecreations, ...(snapshot.overloadLatched ? { overloadLatched: true, overloadReason: 'unreplayable-invalidation' } : {}), ...(snapshot.overflowCount ? { overflowCount: snapshot.overflowCount } : {}), }, storage: { configured: storageSnapshot.configured, status: storageSnapshot.status, breaker: storageSnapshot.breaker, consecutiveFailures: storageSnapshot.consecutiveFailures, circuitRejections: storageSnapshot.circuitRejections, lastSuccessAgeMs: storageSnapshot.lastSuccessAgeMs, lastErrorKind: storageSnapshot.lastErrorKind, }, }}
app.get('/health', (c) => { const response = getCacheInvalidationHealthResponse( getCacheInvalidationHealthSnapshot(), Boolean(process.env.REDIS_URL), ) // Fail closed: a degraded invalidation pipeline must surface as an unhealthy // probe (HTTP 503), not a 200 that orchestrators read as ready. return c.json(response, response.status === 'ok' ? 200 : 503)})
// Liveness only: the process is up and serving HTTP. Dependency readiness is// /health, which fails closed whenever invalidation or storage is degraded.app.get('/live', (c) => c.json({ status: 'ok' }))
type SiteRequest = { url: URL hostname: string normalizedPath: string publicPath: string}
type SiteRequestFailure = 'invalidUrlEncoding' | 'invalidPath'type SiteRequestParseResult = { kind: 'valid'; request: SiteRequest } | { kind: SiteRequestFailure }
type SharedSitePath = { identifier: string site: string filePath: string}
type SharedSiteError = 'invalidPathFormat' | 'invalidIdentifier' | 'siteNameRequired' | 'invalidSiteName'type DnsHashHostname = { hash: string; baseDomain: string }
const SITE_REQUEST_ERROR_MESSAGES = { invalidUrlEncoding: 'Invalid URL encoding', invalidPath: 'Invalid path',} satisfies Record<SiteRequestFailure, string>
const SHARED_SITE_ERROR_MESSAGES = { invalidPathFormat: 'Invalid path format. Expected: /identifier/sitename/path', invalidIdentifier: 'Invalid identifier', siteNameRequired: 'Site name required', invalidSiteName: 'Invalid site name',} satisfies Record<SharedSiteError, string>
function parseSiteRequest(requestUrl: string): SiteRequestParseResult { const url = new URL(requestUrl) const decodedPathname = decodeRequestPathname(url.pathname) if (decodedPathname === null) return { kind: 'invalidUrlEncoding' } if (!decodedPathname.startsWith('/')) return { kind: 'invalidPath' }
const rawPath = decodedPathname.slice(1) const normalizedPath = normalizeSitePath(rawPath, { allowTrailingSlash: true }) if (normalizedPath === null) return { kind: 'invalidPath' }
const publicPath = rawPath.endsWith('/') && normalizedPath ? `${normalizedPath}/` : normalizedPath return { kind: 'valid', request: { url, hostname: url.hostname.toLowerCase().replace(/\.$/, ''), normalizedPath, publicPath }, }}
function siteRequestErrorResponse(c: Context, error: SiteRequestFailure): Response { return c.text(SITE_REQUEST_ERROR_MESSAGES[error], 400)}
function parseSharedSitePath(normalizedPath: string): SharedSitePath | null { const pathParts = normalizedPath.split('/') if (pathParts.length < 2) return null return { identifier: pathParts[0] ?? '', site: pathParts[1] ?? '', filePath: pathParts.slice(2).join('/'), }}
function validateSharedSitePath(sitePath: SharedSitePath): SharedSiteError | null { if (!isValidAtprotoIdentifier(sitePath.identifier)) return 'invalidIdentifier' if (!sitePath.site) return 'siteNameRequired' if (!isValidRkey(sitePath.site)) return 'invalidSiteName' return null}
function sharedSiteErrorResponse(c: Context, error: SharedSiteError): Response { return c.text(SHARED_SITE_ERROR_MESSAGES[error], 400)}
function needsSharedSiteRootRedirect(request: SiteRequest, sitePath: SharedSitePath): boolean { return !sitePath.filePath && !request.url.pathname.endsWith('/')}
function serveSharedSiteFile(c: Context, sitePath: SharedSitePath, did: string): Promise<Response> { logger.debug( `sites.wisp.place request: identifier=${sitePath.identifier}, site=${sitePath.site}, filePath=${sitePath.filePath}`, ) const basePath = `/${sitePath.identifier}/${sitePath.site}/` logger.debug(`Serving with basePath: ${basePath}`) const headers = extractHeaders(c.req.raw.headers) return trackPublicSiteResponse( did, sitePath.site, c.req.method, serveFromCacheWithRewrite(did, sitePath.site, sitePath.filePath, basePath, c.req.url, headers, { method: c.req.method, signal: c.req.raw.signal, }), )}
async function serveSharedSite(c: Context, request: SiteRequest): Promise<Response> { const sitePath = parseSharedSitePath(request.normalizedPath) if (!sitePath) return sharedSiteErrorResponse(c, 'invalidPathFormat')
const validationError = validateSharedSitePath(sitePath) if (validationError) return sharedSiteErrorResponse(c, validationError)
const did = await resolveDidCached(sitePath.identifier) if (!did) return sharedSiteErrorResponse(c, 'invalidIdentifier') if (needsSharedSiteRootRedirect(request, sitePath)) return c.redirect(`${request.url.pathname}/${request.url.search}`, 301) return serveSharedSiteFile(c, sitePath, did)}
function privateSiteResponse(c: Context, request: SiteRequest): Response | Promise<Response> | null { const privateSiteId = siteIdFromHostname(request.hostname, PRIVATE_HOST) if (privateSiteId !== null) return servePrivateSite(c.req.raw, privateSiteId, request.normalizedPath) return request.hostname === PRIVATE_HOST ? privateNotFound() : null}
function parseDnsHashHostname(hostname: string): DnsHashHostname | null { const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/) if (!dnsMatch) return null return { hash: dnsMatch[1] ?? '', baseDomain: dnsMatch[2] ?? '' }}
async function serveDnsHashDomain(c: Context, request: SiteRequest): Promise<Response | null> { const dnsHostname = parseDnsHashHostname(request.hostname) if (!dnsHostname) return null if (!dnsHostname.hash) return c.text('Invalid DNS hash', 400) if (dnsHostname.baseDomain !== BASE_HOST) return c.text('Invalid base domain', 400)
const customDomain = await getCustomDomainByHash(dnsHostname.hash) return serveMappedPublicDomain(c, customDomain, request.publicPath, 'Custom domain not found or not verified')}
async function servePublicDomain(c: Context, request: SiteRequest): Promise<Response> { const dnsResponse = await serveDnsHashDomain(c, request) if (dnsResponse) return dnsResponse
if (request.hostname.endsWith(`.${BASE_HOST}`)) { const domainInfo = await getWispDomain(request.hostname) return serveMappedPublicDomain(c, domainInfo, request.publicPath, 'Subdomain not registered') }
const customDomain = await getCustomDomain(request.hostname) return serveMappedPublicDomain(c, customDomain, request.publicPath, 'Custom domain not found or not verified')}
function logSiteRequest(request: SiteRequest): void { logger.debug(`Request: hostname=${request.hostname} path=${request.publicPath}`)}
async function routeSiteRequest(c: Context, request: SiteRequest): Promise<Response> { logSiteRequest(request) const privateResponse = privateSiteResponse(c, request) if (privateResponse) return await privateResponse if (request.hostname === `sites.${BASE_HOST}`) return await serveSharedSite(c, request) return await servePublicDomain(c, request)}
// Main site serving routeapp.get('/*', (c) => { const requestResult = parseSiteRequest(c.req.url) if (requestResult.kind !== 'valid') return siteRequestErrorResponse(c, requestResult.kind) return routeSiteRequest(c, requestResult.request)})
export default app