From d8222ec75778cbe3787085edcee0ffc1cfbe5a1e Mon Sep 17 00:00:00 2001 From: saltbo Date: Sun, 26 Jul 2026 14:15:17 -0400 Subject: [PATCH] perf(cache): add multi-runtime layered caching --- package.json | 6 + server/adapters/cache/cloudflare-kv.ts | 21 ++ server/adapters/cache/runtime-cache.test.ts | 154 +++++++++++++++ server/adapters/cache/runtime-cache.ts | 185 ++++++++++++++++++ server/app.ts | 50 +++-- server/bootstrap.ts | 6 +- server/cache/context.ts | 36 ++++ server/composition.ts | 17 +- server/entry-node.ts | 2 +- server/http/configz.ts | 29 ++- server/http/site/settings.integration.test.ts | 29 +++ .../traffic-metering.integration.test.ts | 4 +- .../image-hosting-domain.integration.test.ts | 8 +- server/middleware/image-hosting-domain.ts | 17 +- server/middleware/logger.ts | 2 + server/usecases/deps.ts | 2 + server/usecases/image-hosting/config.ts | 6 + server/usecases/image-hosting/domain-cache.ts | 40 ++++ server/usecases/ports.ts | 1 + server/usecases/ports/cache.ts | 30 +++ server/usecases/site/auth-provider.ts | 15 +- server/usecases/site/branding.ts | 6 +- server/usecases/site/config-cache.ts | 22 +++ server/usecases/site/configz.ts | 15 +- server/usecases/site/public-origin.test.ts | 5 +- server/usecases/site/public-origin.ts | 24 +-- server/usecases/site/routing-config.ts | 59 ++++++ server/usecases/site/setting-keys.ts | 28 +++ server/usecases/site/settings.ts | 49 ++--- server/usecases/site/system.test.ts | 4 +- workers/bootstrap.cf-test.ts | 18 ++ workers/bootstrap.ts | 150 +++++++++++--- wrangler.toml | 12 ++ 33 files changed, 940 insertions(+), 112 deletions(-) create mode 100644 server/adapters/cache/cloudflare-kv.ts create mode 100644 server/adapters/cache/runtime-cache.test.ts create mode 100644 server/adapters/cache/runtime-cache.ts create mode 100644 server/cache/context.ts create mode 100644 server/usecases/image-hosting/domain-cache.ts create mode 100644 server/usecases/ports/cache.ts create mode 100644 server/usecases/site/config-cache.ts create mode 100644 server/usecases/site/routing-config.ts create mode 100644 server/usecases/site/setting-keys.ts diff --git a/package.json b/package.json index 8243343d..0a0a0b13 100644 --- a/package.json +++ b/package.json @@ -148,6 +148,12 @@ }, "DB": { "description": "D1 database for storing users, files, and settings" + }, + "CACHE_KV": { + "description": "Workers KV namespace for distributed application caches" + }, + "ZPAN_CACHE_MODE": { + "description": "Cache mode: off, memory, or distributed" } } }, diff --git a/server/adapters/cache/cloudflare-kv.ts b/server/adapters/cache/cloudflare-kv.ts new file mode 100644 index 00000000..e84c9a7d --- /dev/null +++ b/server/adapters/cache/cloudflare-kv.ts @@ -0,0 +1,21 @@ +import type { DistributedCacheBackend } from '../../usecases/ports/cache' + +export interface CloudflareKvNamespaceLike { + get(key: string, options: { cacheTtl: number }): Promise + put(key: string, value: string, options: { expirationTtl: number }): Promise + delete(key: string): Promise +} + +export function createCloudflareKvBackend(namespace: CloudflareKvNamespaceLike): DistributedCacheBackend { + return { + get(key, cacheTtlSeconds) { + return namespace.get(key, { cacheTtl: cacheTtlSeconds }) + }, + put(key, value, expirationTtlSeconds) { + return namespace.put(key, value, { expirationTtl: expirationTtlSeconds }) + }, + delete(key) { + return namespace.delete(key) + }, + } +} diff --git a/server/adapters/cache/runtime-cache.test.ts b/server/adapters/cache/runtime-cache.test.ts new file mode 100644 index 00000000..15aa4489 --- /dev/null +++ b/server/adapters/cache/runtime-cache.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, vi } from 'vitest' +import type { CachePolicy, DistributedCacheBackend } from '../../usecases/ports' +import { createRuntimeCache, resolveCacheMode } from './runtime-cache' + +const stringPolicy: CachePolicy = { + namespace: 'test', + version: 2, + ttlMs: 1_000, + negativeTtlMs: 100, + maxEntries: 2, + validate(value): value is string | null { + return typeof value === 'string' || value === null + }, +} + +function fakeBackend() { + const values = new Map() + const backend: DistributedCacheBackend = { + get: vi.fn(async (key) => values.get(key) ?? null), + put: vi.fn(async (key, value) => { + values.set(key, value) + }), + delete: vi.fn(async (key) => { + values.delete(key) + }), + } + return { backend, values } +} + +describe('runtime cache', () => { + it('bypasses every tier when disabled', async () => { + const loader = vi.fn(async () => 'source') + const cache = createRuntimeCache({ mode: 'off' }) + + expect(await cache.getOrLoad(stringPolicy, 'a', loader)).toMatchObject({ value: 'source', tier: 'bypass' }) + expect(await cache.getOrLoad(stringPolicy, 'a', loader)).toMatchObject({ value: 'source', tier: 'bypass' }) + expect(loader).toHaveBeenCalledTimes(2) + }) + + it('serves fresh values from memory and reloads expired values', async () => { + let now = 1_000 + const loader = vi.fn(async () => `value-${loader.mock.calls.length}`) + const cache = createRuntimeCache({ mode: 'memory', now: () => now }) + + expect((await cache.getOrLoad(stringPolicy, 'a', loader)).tier).toBe('source') + expect(await cache.getOrLoad(stringPolicy, 'a', loader)).toMatchObject({ value: 'value-1', tier: 'memory' }) + now = 2_001 + expect(await cache.getOrLoad(stringPolicy, 'a', loader)).toMatchObject({ value: 'value-2', tier: 'source' }) + }) + + it('uses the shorter negative-cache TTL', async () => { + let now = 1_000 + const loader = vi.fn(async () => null) + const cache = createRuntimeCache({ mode: 'memory', now: () => now }) + + await cache.getOrLoad(stringPolicy, 'missing', loader) + now = 1_099 + expect((await cache.getOrLoad(stringPolicy, 'missing', loader)).tier).toBe('memory') + now = 1_101 + expect((await cache.getOrLoad(stringPolicy, 'missing', loader)).tier).toBe('source') + }) + + it('evicts the least recently used entry at the policy capacity', async () => { + const cache = createRuntimeCache({ mode: 'memory' }) + await cache.replace(stringPolicy, 'a', 'a') + await cache.replace(stringPolicy, 'b', 'b') + await cache.getOrLoad(stringPolicy, 'a', async () => 'unexpected') + await cache.replace(stringPolicy, 'c', 'c') + + expect((await cache.getOrLoad(stringPolicy, 'a', async () => 'unexpected')).tier).toBe('memory') + expect((await cache.getOrLoad(stringPolicy, 'b', async () => 'reloaded')).tier).toBe('source') + }) + + it('reads through distributed cache and populates memory', async () => { + let now = 1_000 + const { backend, values } = fakeBackend() + values.set('zpan:v2:test:a', JSON.stringify({ freshUntil: 1_500, value: 'kv' })) + const cache = createRuntimeCache({ mode: 'distributed', distributed: backend, now: () => now }) + const loader = vi.fn(async () => 'source') + + expect(await cache.getOrLoad(stringPolicy, 'a', loader)).toMatchObject({ value: 'kv', tier: 'distributed' }) + now = 1_100 + expect((await cache.getOrLoad(stringPolicy, 'a', loader)).tier).toBe('memory') + expect(loader).not.toHaveBeenCalled() + }) + + it('rejects expired or invalid distributed envelopes and refreshes them from source', async () => { + const { backend, values } = fakeBackend() + values.set('zpan:v2:test:expired', JSON.stringify({ freshUntil: 999, value: 'old' })) + values.set('zpan:v2:test:invalid', JSON.stringify({ freshUntil: 2_000, value: 123 })) + const cache = createRuntimeCache({ mode: 'distributed', distributed: backend, now: () => 1_000 }) + + expect(await cache.getOrLoad(stringPolicy, 'expired', async () => 'fresh')).toMatchObject({ + value: 'fresh', + tier: 'source', + }) + expect(await cache.getOrLoad(stringPolicy, 'invalid', async () => 'valid')).toMatchObject({ + value: 'valid', + tier: 'source', + }) + }) + + it('falls back to source when distributed reads fail', async () => { + const backend: DistributedCacheBackend = { + get: vi.fn(async () => { + throw new Error('unavailable') + }), + put: vi.fn(async () => { + throw new Error('unavailable') + }), + delete: vi.fn(async () => { + throw new Error('unavailable') + }), + } + const cache = createRuntimeCache({ mode: 'distributed', distributed: backend }) + + expect(await cache.getOrLoad(stringPolicy, 'a', async () => 'source')).toMatchObject({ + value: 'source', + tier: 'source', + }) + await expect(cache.invalidate(stringPolicy, 'a')).resolves.toBeUndefined() + }) + + it('replace updates both tiers and invalidate removes both tiers', async () => { + const { backend, values } = fakeBackend() + const cache = createRuntimeCache({ mode: 'distributed', distributed: backend }) + + await cache.replace(stringPolicy, 'a', 'new') + expect((await cache.getOrLoad(stringPolicy, 'a', async () => 'source')).value).toBe('new') + expect(values.has('zpan:v2:test:a')).toBe(true) + + await cache.invalidate(stringPolicy, 'a') + expect(values.has('zpan:v2:test:a')).toBe(false) + expect(await cache.getOrLoad(stringPolicy, 'a', async () => 'source')).toMatchObject({ + value: 'source', + tier: 'source', + }) + }) +}) + +describe('resolveCacheMode', () => { + it('uses the runtime-appropriate default', () => { + expect(resolveCacheMode(undefined, false)).toBe('memory') + expect(resolveCacheMode(undefined, true)).toBe('distributed') + }) + + it('validates explicit values and distributed availability', () => { + expect(resolveCacheMode('off', false)).toBe('off') + expect(resolveCacheMode('memory', true)).toBe('memory') + expect(() => resolveCacheMode('invalid', false)).toThrow('Invalid ZPAN_CACHE_MODE') + expect(() => resolveCacheMode('distributed', false)).toThrow('requires a distributed cache binding') + expect(() => createRuntimeCache({ mode: 'distributed' })).toThrow('requires a backend') + }) +}) diff --git a/server/adapters/cache/runtime-cache.ts b/server/adapters/cache/runtime-cache.ts new file mode 100644 index 00000000..409a89eb --- /dev/null +++ b/server/adapters/cache/runtime-cache.ts @@ -0,0 +1,185 @@ +import { recordCacheEvent } from '../../cache/context' +import type { + CacheMode, + CachePolicy, + CacheResult, + CacheService, + CacheTier, + DistributedCacheBackend, +} from '../../usecases/ports/cache' + +interface CacheEnvelope { + freshUntil: number + value: unknown +} + +interface MemoryEntry { + freshUntil: number + value: unknown +} + +const KV_CACHE_TTL_SECONDS = 60 +const KV_EXPIRATION_TTL_SECONDS = 600 + +export interface RuntimeCacheOptions { + mode: CacheMode + distributed?: DistributedCacheBackend + now?: () => number +} + +export function resolveCacheMode(value: string | undefined, distributedAvailable: boolean): CacheMode { + const mode = value?.trim() || (distributedAvailable ? 'distributed' : 'memory') + if (mode !== 'off' && mode !== 'memory' && mode !== 'distributed') { + throw new Error(`Invalid ZPAN_CACHE_MODE: ${mode}`) + } + if (mode === 'distributed' && !distributedAvailable) { + throw new Error('ZPAN_CACHE_MODE=distributed requires a distributed cache binding') + } + return mode +} + +export function createRuntimeCache(options: RuntimeCacheOptions): CacheService { + if (options.mode === 'distributed' && !options.distributed) { + throw new Error('Distributed cache mode requires a backend') + } + + const stores = new Map>() + const now = options.now ?? Date.now + + function memoryStore(namespace: string): Map { + let store = stores.get(namespace) + if (!store) { + store = new Map() + stores.set(namespace, store) + } + return store + } + + function memoryGet(policy: CachePolicy, key: string): T | undefined { + const store = memoryStore(policy.namespace) + const entry = store.get(key) + if (!entry) return undefined + if (entry.freshUntil <= now() || !policy.validate(entry.value)) { + store.delete(key) + return undefined + } + store.delete(key) + store.set(key, entry) + return entry.value + } + + function ttlFor(policy: CachePolicy, value: T): number { + return value === null ? (policy.negativeTtlMs ?? policy.ttlMs) : policy.ttlMs + } + + function memoryPut(policy: CachePolicy, key: string, value: T, freshUntil = now() + ttlFor(policy, value)) { + const store = memoryStore(policy.namespace) + store.delete(key) + store.set(key, { freshUntil, value }) + while (store.size > policy.maxEntries) { + const oldest = store.keys().next().value + if (oldest === undefined) break + store.delete(oldest) + } + } + + function cacheKey(policy: CachePolicy, key: string): string { + return `zpan:v${policy.version}:${policy.namespace}:${key}` + } + + async function distributedGet(policy: CachePolicy, key: string): Promise { + if (!options.distributed) return undefined + try { + const raw = await options.distributed.get(cacheKey(policy, key), KV_CACHE_TTL_SECONDS) + if (!raw) return undefined + const envelope = JSON.parse(raw) as CacheEnvelope + if ( + typeof envelope !== 'object' || + envelope === null || + typeof envelope.freshUntil !== 'number' || + envelope.freshUntil <= now() || + !policy.validate(envelope.value) + ) { + return undefined + } + memoryPut(policy, key, envelope.value, envelope.freshUntil) + return envelope.value + } catch (error) { + console.error( + JSON.stringify({ + message: 'cache.distributed.get.error', + namespace: policy.namespace, + error: error instanceof Error ? error.message : String(error), + }), + ) + return undefined + } + } + + async function distributedPut(policy: CachePolicy, key: string, value: T, freshUntil: number): Promise { + if (!options.distributed) return + const envelope: CacheEnvelope = { freshUntil, value } + try { + await options.distributed.put(cacheKey(policy, key), JSON.stringify(envelope), KV_EXPIRATION_TTL_SECONDS) + } catch (error) { + console.error( + JSON.stringify({ + message: 'cache.distributed.put.error', + namespace: policy.namespace, + error: error instanceof Error ? error.message : String(error), + }), + ) + } + } + + function observed(namespace: string, tier: CacheTier, startedAt: number, value: T): CacheResult { + recordCacheEvent({ namespace, tier, durationMs: performance.now() - startedAt }) + return { value, tier } + } + + return { + mode: options.mode, + + async getOrLoad(policy: CachePolicy, key: string, loader: () => Promise): Promise> { + const startedAt = performance.now() + if (options.mode === 'off') return observed(policy.namespace, 'bypass', startedAt, await loader()) + + const inMemory = memoryGet(policy, key) + if (inMemory !== undefined) return observed(policy.namespace, 'memory', startedAt, inMemory) + + if (options.mode === 'distributed') { + const distributed = await distributedGet(policy, key) + if (distributed !== undefined) return observed(policy.namespace, 'distributed', startedAt, distributed) + } + + const value = await loader() + const freshUntil = now() + ttlFor(policy, value) + memoryPut(policy, key, value, freshUntil) + if (options.mode === 'distributed') await distributedPut(policy, key, value, freshUntil) + return observed(policy.namespace, 'source', startedAt, value) + }, + + async replace(policy: CachePolicy, key: string, value: T): Promise { + if (options.mode === 'off') return + const freshUntil = now() + ttlFor(policy, value) + memoryPut(policy, key, value, freshUntil) + if (options.mode === 'distributed') await distributedPut(policy, key, value, freshUntil) + }, + + async invalidate(policy: CachePolicy, key: string): Promise { + memoryStore(policy.namespace).delete(key) + if (options.mode !== 'distributed' || !options.distributed) return + try { + await options.distributed.delete(cacheKey(policy, key)) + } catch (error) { + console.error( + JSON.stringify({ + message: 'cache.distributed.delete.error', + namespace: policy.namespace, + error: error instanceof Error ? error.message : String(error), + }), + ) + } + }, + } +} diff --git a/server/app.ts b/server/app.ts index 20d1df6c..180a60fd 100644 --- a/server/app.ts +++ b/server/app.ts @@ -4,6 +4,7 @@ import { Scalar } from '@scalar/hono-api-reference' import type { Context } from 'hono' import { cors } from 'hono/cors' import type { Auth } from './auth' +import { cacheServerTiming, runWithCacheEvents } from './cache/context' import { createDeps } from './composition' import { isPotentialWebDavPublicRequest, isWebDavPublicRequest } from './domain/webdav-public-url' import { adminOverview } from './http/admin-overview' @@ -51,28 +52,32 @@ import type { Platform } from './platform/interface' import { getDeployPlatform } from './runtime-platform' import type { Deps } from './usecases/deps' import { INSTANCE_TELEMETRY_CRON, reportInstanceTelemetry } from './usecases/site/instance-telemetry' -import { ensureSitePublicOrigin, getSitePublicOrigin } from './usecases/site/public-origin' -import { getSiteWebDavRuntimeConfig } from './usecases/site/settings' +import { ensureSitePublicOrigin } from './usecases/site/public-origin' +import { getSiteRoutingConfig } from './usecases/site/routing-config' export function createApp(platform: Platform, auth: Auth, deps: Deps = createDeps(platform)) { const app = new OpenAPIHono() const corsOrigins = getCorsOrigins(platform) app.use('/*', platformMiddleware(platform, auth)) + app.use('/*', async (c, next) => + runWithCacheEvents(async () => { + await next() + const serverTiming = cacheServerTiming() + if (serverTiming) c.res.headers.append('Server-Timing', serverTiming) + }), + ) app.use('/*', async (c, next) => { c.set('deps', deps) await next() }) app.use('/*', async (c, next) => { if (isPotentialWebDavPublicRequest(c.req.url)) { - const [sitePublicOrigin, webDavConfig] = await Promise.all([ - getSitePublicOrigin(deps), - getSiteWebDavRuntimeConfig(deps), - ]) - c.set('webDavEnabled', webDavConfig.enabled) - c.set('webDavDomain', webDavConfig.domain) - const routingOrigin = sitePublicOrigin ?? (webDavConfig.domain ? new URL(c.req.url).origin : null) - if (webDavConfig.enabled && isWebDavPublicRequest(c.req.url, routingOrigin, webDavConfig.domain)) { + const routing = await getSiteRoutingConfig(deps) + c.set('webDavEnabled', routing.webDavEnabled) + c.set('webDavDomain', routing.webDavDomain) + const routingOrigin = routing.publicOrigin ?? (routing.webDavDomain ? new URL(c.req.url).origin : null) + if (routing.webDavEnabled && isWebDavPublicRequest(c.req.url, routingOrigin, routing.webDavDomain)) { c.set('sitePublicOrigin', routingOrigin) c.set('webDavMountPath', '') await next() @@ -83,6 +88,7 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep console.error(`site.public_origin.detect.error code=${formatError(err)}`) return { origin: null, created: false } }) + c.set('sitePublicOrigin', result.origin) if (result.created && result.origin && shouldReportInitialTelemetry(c.req.url)) { const task = reportInstanceTelemetry(deps, { @@ -188,13 +194,31 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep app.route('/dav', webdav) - // Resolve the caller's principal for every /api/* route. authMiddleware is + // Resolve the caller's principal for API routes that can use it. Public, + // identity-independent health/config responses skip session resolution so + // their hot path does not touch auth tables. + const skipsPrincipalResolution = (path: string) => + path === '/api/configz' || path === '/api/configz/' || path === '/api/health' + + // authMiddleware is // soft-fail: it populates userId/orgId/principal (or null) and never rejects an // anonymous caller — per-route guards (requireAuth/requireAdmin/requireTeamRole, // or a shared-secret/signature check) do the gating. Running it ahead of every // /api route lets one router per resource mix public and authed endpoints. - app.use('/api/*', authMiddleware) - app.use('/api/*', auditMiddleware) + app.use('/api/*', async (c, next) => { + if (skipsPrincipalResolution(c.req.path)) { + await next() + return + } + await authMiddleware(c, next) + }) + app.use('/api/*', async (c, next) => { + if (skipsPrincipalResolution(c.req.path)) { + await next() + return + } + await auditMiddleware(c, next) + }) // Public routes — no per-route auth guard. // /api/shares/:token endpoints are covered by run_worker_first=["/api/*"] in wrangler.toml. diff --git a/server/bootstrap.ts b/server/bootstrap.ts index 60eddfd4..12abe57b 100644 --- a/server/bootstrap.ts +++ b/server/bootstrap.ts @@ -1,8 +1,10 @@ import { createApp } from './app' import { createAuth } from './auth' +import { createDeps } from './composition' import type { Platform } from './platform/interface' +import type { Deps } from './usecases/deps' -export async function createBootstrap(platform: Platform) { +export async function createBootstrap(platform: Platform, deps: Deps = createDeps(platform)) { const secret = platform.getEnv('BETTER_AUTH_SECRET') if (!secret) { throw new Error('BETTER_AUTH_SECRET is required. Set it in the environment before starting the server.') @@ -16,5 +18,5 @@ export async function createBootstrap(platform: Platform) { .filter(Boolean) || ['http://localhost:5185'] const auth = await createAuth(platform, secret, baseURL, trustedOrigins) - return createApp(platform, auth) + return createApp(platform, auth, deps) } diff --git a/server/cache/context.ts b/server/cache/context.ts new file mode 100644 index 00000000..754c51dc --- /dev/null +++ b/server/cache/context.ts @@ -0,0 +1,36 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import type { CacheTier } from '../usecases/ports/cache' + +export interface CacheEvent { + namespace: string + tier: CacheTier + durationMs: number +} + +const cacheEvents = new AsyncLocalStorage() + +export function runWithCacheEvents(callback: () => T): T { + return cacheEvents.run([], callback) +} + +export function recordCacheEvent(event: CacheEvent): void { + cacheEvents.getStore()?.push(event) +} + +export function currentCacheEvents(): readonly CacheEvent[] { + return cacheEvents.getStore() ?? [] +} + +export function cacheServerTiming(): string | null { + const events = currentCacheEvents() + if (events.length === 0) return null + return events + .map(({ namespace, tier, durationMs }) => `zpan-cache;dur=${durationMs.toFixed(1)};desc="${namespace}:${tier}"`) + .join(', ') +} + +export function cacheLogSummary(): string { + const events = currentCacheEvents() + if (events.length === 0) return '-' + return events.map(({ namespace, tier }) => `${namespace}:${tier}`).join(',') +} diff --git a/server/composition.ts b/server/composition.ts index 58d25eec..95e1f372 100644 --- a/server/composition.ts +++ b/server/composition.ts @@ -4,6 +4,8 @@ // entrypoints can reuse it; request-bound capabilities are passed to usecases as // function parameters, never stored here. +import { type CloudflareKvNamespaceLike, createCloudflareKvBackend } from './adapters/cache/cloudflare-kv' +import { createRuntimeCache, resolveCacheMode } from './adapters/cache/runtime-cache' import { createArchiveJobsGateway } from './adapters/gateways/archive-jobs' import { createEmailGateway } from './adapters/gateways/email' import { createImageUploadGateway } from './adapters/gateways/image-upload' @@ -51,8 +53,13 @@ import { createWebDavStateRepo } from './adapters/repos/webdav-state' import { createZipPlanRepo } from './adapters/repos/zip' import type { Platform } from './platform/interface' import type { Deps } from './usecases/deps' +import type { CacheService } from './usecases/ports' -export function createDeps(platform: Platform): Deps { +export interface CreateDepsOptions { + cache?: CacheService +} + +export function createDeps(platform: Platform, options: CreateDepsOptions = {}): Deps { const { db } = platform // Shared stateless instances reused by multiple ports below. const s3 = new S3Service() @@ -60,6 +67,13 @@ export function createDeps(platform: Platform): Deps { const systemOptions = createSystemOptionsRepo(db) const licenseBinding = createLicenseBindingRepo(db) const licensingCloud = createLicensingCloudGateway() + const cacheNamespace = platform.getBinding('CACHE_KV') + const cache = + options.cache ?? + createRuntimeCache({ + mode: resolveCacheMode(platform.getEnv('ZPAN_CACHE_MODE'), !!cacheNamespace), + distributed: cacheNamespace ? createCloudflareKvBackend(cacheNamespace) : undefined, + }) return { audit: createAuditRepo(db), adminStats: createAdminStatsRepo(db), @@ -68,6 +82,7 @@ export function createDeps(platform: Platform): Deps { archiveJobs: createArchiveJobsGateway(platform), archiveTargetFolders: createArchiveTargetFolderRepo(db), backgroundJobs: createBackgroundJobRepo(db), + cache, cfHostnames: createCfClient((key) => platform.getEnv(key)), changelog: createChangelogProvider(), cloudStore: createCloudStoreRepo(db), diff --git a/server/entry-node.ts b/server/entry-node.ts index 2d95608d..5fe2494a 100644 --- a/server/entry-node.ts +++ b/server/entry-node.ts @@ -58,7 +58,7 @@ const platform = process.env.TURSO_DATABASE_URL : createNodePlatform() const deps = createDeps(platform) -const app = await createBootstrap(platform) +const app = await createBootstrap(platform, deps) const server = new Hono() server.route('/', app) diff --git a/server/http/configz.ts b/server/http/configz.ts index 6365ba16..14447ccb 100644 --- a/server/http/configz.ts +++ b/server/http/configz.ts @@ -1,6 +1,8 @@ import { createRoute, OpenAPIHono } from '@hono/zod-openapi' import { siteConfigSchema } from '@shared/schemas' +import { currentCacheEvents } from '../cache/context' import type { Env } from '../middleware/platform' +import { siteConfigCacheControl } from '../usecases/site/config-cache' import { getSiteConfig } from '../usecases/site/configz' import { jsonContent } from './openapi' @@ -10,9 +12,28 @@ const getRoute = createRoute({ tags: ['Site Config'], method: 'get', path: '/', - responses: { 200: jsonContent(siteConfigSchema, 'Public site configuration') }, + responses: { + 200: jsonContent(siteConfigSchema, 'Public site configuration'), + 304: { description: 'Not modified' }, + }, }) -export const configz = new OpenAPIHono().openapi(getRoute, async (c) => - c.json(await getSiteConfig(c.get('deps'), c.req.url), 200), -) +const encoder = new TextEncoder() + +async function responseEtag(body: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', encoder.encode(body)) + return `"${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')}"` +} + +export const configz = new OpenAPIHono().openapi(getRoute, async (c) => { + const config = await getSiteConfig(c.get('deps'), c.req.url) + const body = JSON.stringify(config) + const etag = await responseEtag(body) + const cacheControl = siteConfigCacheControl(c.get('deps')) + const headers = { 'Cache-Control': cacheControl, ETag: etag } + const cacheTier = + [...currentCacheEvents()].reverse().find((event) => event.namespace === 'site-config')?.tier ?? 'source' + const responseHeaders = { ...headers, 'X-ZPan-Cache': cacheTier } + if (c.req.header('If-None-Match') === etag) return c.body(null, 304, responseHeaders) + return c.json(config, 200, responseHeaders) +}) diff --git a/server/http/site/settings.integration.test.ts b/server/http/site/settings.integration.test.ts index ccb2063d..f1704448 100644 --- a/server/http/site/settings.integration.test.ts +++ b/server/http/site/settings.integration.test.ts @@ -30,6 +30,35 @@ describe('Site configuration API', () => { expect(body).toHaveProperty('branding') }) + it('caches configz in memory and supports conditional requests', async () => { + const { app } = await createTestApp() + const first = await app.request('https://pan.example.com/api/configz') + const etag = first.headers.get('etag') + + expect(first.headers.get('x-zpan-cache')).toBe('source') + expect(first.headers.get('cache-control')).toContain('s-maxage=60') + expect(etag).toMatch(/^"[a-f0-9]{64}"$/) + + const second = await app.request('https://pan.example.com/api/configz') + expect(second.headers.get('x-zpan-cache')).toBe('memory') + + const conditional = await app.request('https://pan.example.com/api/configz', { + headers: { 'If-None-Match': etag ?? '' }, + }) + expect(conditional.status).toBe(304) + expect(await conditional.text()).toBe('') + }) + + it('disables configz caching in off mode', async () => { + const { app } = await createTestApp({ ZPAN_CACHE_MODE: 'off' }) + const first = await app.request('https://pan.example.com/api/configz') + const second = await app.request('https://pan.example.com/api/configz') + + expect(first.headers.get('cache-control')).toBe('no-store') + expect(first.headers.get('x-zpan-cache')).toBe('bypass') + expect(second.headers.get('x-zpan-cache')).toBe('bypass') + }) + it('requires admin for structured settings and removes generic Options [spec: system/settings-admin-only]', async () => { const { app } = await createTestApp() expect((await app.request('/api/site/settings')).status).toBe(401) diff --git a/server/http/store/traffic-metering.integration.test.ts b/server/http/store/traffic-metering.integration.test.ts index 75ec7ab0..dfe2049b 100644 --- a/server/http/store/traffic-metering.integration.test.ts +++ b/server/http/store/traffic-metering.integration.test.ts @@ -477,7 +477,7 @@ describe('public redirect cloud traffic reporting', () => { }) it('reports custom-domain image-hosting redirects to Cloud', async () => { - const { app, db } = await createTestApp({ PUBLIC_APP_HOST: 'zpan.example.com' }) + const { app, db } = await createTestApp() await seedTrafficBinding(db) vi.stubGlobal('fetch', vi.fn().mockImplementation(acceptedUsageResponse)) await authedHeaders(app) @@ -498,7 +498,7 @@ describe('public redirect cloud traffic reporting', () => { }) it('still redirects custom-domain images when access-count recording fails after local traffic queue', async () => { - const { app, db } = await createTestApp({ PUBLIC_APP_HOST: 'zpan.example.com' }) + const { app, db } = await createTestApp() await seedTrafficBinding(db) vi.stubGlobal('fetch', vi.fn().mockImplementation(acceptedUsageResponse)) const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) diff --git a/server/middleware/image-hosting-domain.integration.test.ts b/server/middleware/image-hosting-domain.integration.test.ts index 41e67047..6c68890e 100644 --- a/server/middleware/image-hosting-domain.integration.test.ts +++ b/server/middleware/image-hosting-domain.integration.test.ts @@ -86,15 +86,17 @@ beforeEach(() => { describe('imageHostingDomain middleware — app-host passthrough', () => { it('default app host → next(), normal routing works', async () => { - const { app } = await createTestApp({ PUBLIC_APP_HOST: 'zpan.example.com' }) + const { app, deps } = await createTestApp() + await deps.systemOptions.set('site_public_origin', 'https://zpan.example.com') const res = await app.request('/api/health', { headers: { host: 'zpan.example.com' }, }) expect(res.status).toBe(200) }) - it('subdomain of app host → next()', async () => { - const { app } = await createTestApp({ PUBLIC_APP_HOST: 'zpan.example.com' }) + it('unclaimed subdomain of app host → next()', async () => { + const { app, deps } = await createTestApp() + await deps.systemOptions.set('site_public_origin', 'https://zpan.example.com') const res = await app.request('/api/health', { headers: { host: 'sub.zpan.example.com' }, }) diff --git a/server/middleware/image-hosting-domain.ts b/server/middleware/image-hosting-domain.ts index 050ba8f8..fc2e9a41 100644 --- a/server/middleware/image-hosting-domain.ts +++ b/server/middleware/image-hosting-domain.ts @@ -2,6 +2,7 @@ import type { Context, Next } from 'hono' import { PRESIGN_TTL_SECS } from '../http/share-utils' import { reportTrafficForDownload } from '../http/store/traffic-metering' import type { Env } from '../middleware/platform' +import { resolveCachedImageDomain } from '../usecases/image-hosting/domain-cache' import { forbidden, notFound, quotaExceeded, storageNotFound } from '../usecases/ports' import { confirmDownloadTraffic, reverseDownloadTraffic } from '../usecases/store/traffic-metering' import { createTrafficEventId, recordDownloadFailure, recordDownloadIssued } from '../usecases/transfer-activity' @@ -20,13 +21,9 @@ function normalizeHost(raw: string): string | null { function getAppHostCandidates(c: Context): string[] { const candidates = ['workers.dev'] // *.workers.dev covers preview deployments - const appHost = c.get('platform').getEnv('PUBLIC_APP_HOST') - if (appHost) { - const bare = appHost - .replace(/^https?:\/\//, '') - .split('/')[0] - .toLowerCase() - if (bare) candidates.push(bare) + const publicOrigin = c.get('sitePublicOrigin') + if (publicOrigin) { + candidates.push(new URL(publicOrigin).hostname.toLowerCase()) } return candidates } @@ -153,11 +150,13 @@ export async function imageHostingDomain(c: Context, next: Next): Promise host === h || host.endsWith(`.${h}`))) { + if ( + appHosts.some((candidate) => host === candidate || (candidate === 'workers.dev' && host.endsWith('.workers.dev'))) + ) { return next() } - const orgId = await c.get('deps').imageHosting.resolveCustomDomain(host) + const orgId = await resolveCachedImageDomain(c.get('deps'), host) if (!orgId) return next() const virtualPath = c.req.path.replace(/^\/+/, '') diff --git a/server/middleware/logger.ts b/server/middleware/logger.ts index 9055f9b5..ce59f8b0 100644 --- a/server/middleware/logger.ts +++ b/server/middleware/logger.ts @@ -1,5 +1,6 @@ import type { Context } from 'hono' import { createMiddleware } from 'hono/factory' +import { cacheLogSummary } from '../cache/context' import type { Env } from './platform' // The request boundary for /api and /dav: one structured line per request, logged @@ -28,6 +29,7 @@ function accessLogFields(c: Context, start: number): Array<[string, string ['status', status], ['ms', Date.now() - start], ['uid', c.get('userId') ?? '-'], + ['cache', cacheLogSummary()], ] if (c.req.path.startsWith('/dav/')) { diff --git a/server/usecases/deps.ts b/server/usecases/deps.ts index 87c967d0..9463f7cc 100644 --- a/server/usecases/deps.ts +++ b/server/usecases/deps.ts @@ -10,6 +10,7 @@ import type { ArchiveTargetFolderRepo, AuditRepo, BackgroundJobRepo, + CacheService, CfHostnamesProvider, ChangelogProvider, CloudStoreRepo, @@ -58,6 +59,7 @@ export interface Deps { archiveJobs: ArchiveJobsGateway archiveTargetFolders: ArchiveTargetFolderRepo backgroundJobs: BackgroundJobRepo + cache: CacheService cfHostnames: CfHostnamesProvider changelog: ChangelogProvider cloudStore: CloudStoreRepo diff --git a/server/usecases/image-hosting/config.ts b/server/usecases/image-hosting/config.ts index 1cb78e2d..203f9a05 100644 --- a/server/usecases/image-hosting/config.ts +++ b/server/usecases/image-hosting/config.ts @@ -22,10 +22,12 @@ import { type ImageHostingConfigRecord, type ImageHostingConfigRepo, } from '../ports' +import { cacheVerifiedImageDomain, invalidateImageDomain } from './domain-cache' export type ImageHostingConfigDeps = { imageHostingConfigs: ImageHostingConfigRepo cfHostnames: CfHostnamesProvider + cache?: import('../ports').CacheService } // The request-scoped Cloudflare settings the http layer resolves from the @@ -63,6 +65,7 @@ export async function getImageHostingConfig( const now = new Date() await deps.imageHostingConfigs.update(orgId, { domainVerifiedAt: now }) row.domainVerifiedAt = now + await cacheVerifiedImageDomain(deps, row.customDomain, orgId) } } @@ -118,6 +121,7 @@ export async function putImageHostingConfig( if (isUniqueViolation(e)) return { ok: false, error: domainConflictError() } throw e } + await invalidateImageDomain(deps, newDomain) return { ok: true, @@ -183,6 +187,7 @@ export async function putImageHostingConfig( if (isUniqueViolation(e)) return { ok: false, error: domainConflictError() } throw e } + await Promise.all([invalidateImageDomain(deps, oldDomain), invalidateImageDomain(deps, newDomain)]) return { ok: true, @@ -216,4 +221,5 @@ export async function deleteImageHostingConfig(deps: ImageHostingConfigDeps, org } await deps.imageHostingConfigs.delete(orgId) + await invalidateImageDomain(deps, row.customDomain) } diff --git a/server/usecases/image-hosting/domain-cache.ts b/server/usecases/image-hosting/domain-cache.ts new file mode 100644 index 00000000..156ca1d7 --- /dev/null +++ b/server/usecases/image-hosting/domain-cache.ts @@ -0,0 +1,40 @@ +import type { CachePolicy, CacheService, ImageHostingRepo } from '../ports' + +export const IMAGE_DOMAIN_CACHE_POLICY: CachePolicy = { + namespace: 'image-domain', + version: 1, + ttlMs: 60_000, + negativeTtlMs: 30_000, + maxEntries: 512, + validate(value): value is string | null { + return typeof value === 'string' || value === null + }, +} + +export type ImageDomainCacheDeps = { + cache?: CacheService + imageHosting: ImageHostingRepo +} + +export async function resolveCachedImageDomain(deps: ImageDomainCacheDeps, host: string): Promise { + if (!deps.cache) return deps.imageHosting.resolveCustomDomain(host) + return ( + await deps.cache.getOrLoad(IMAGE_DOMAIN_CACHE_POLICY, host, () => deps.imageHosting.resolveCustomDomain(host)) + ).value +} + +export async function invalidateImageDomain( + deps: Pick, + host: string | null, +): Promise { + if (!host) return + await deps.cache?.invalidate(IMAGE_DOMAIN_CACHE_POLICY, host) +} + +export async function cacheVerifiedImageDomain( + deps: Pick, + host: string, + orgId: string, +): Promise { + await deps.cache?.replace(IMAGE_DOMAIN_CACHE_POLICY, host, orgId) +} diff --git a/server/usecases/ports.ts b/server/usecases/ports.ts index aac061d1..3786ad49 100644 --- a/server/usecases/ports.ts +++ b/server/usecases/ports.ts @@ -11,6 +11,7 @@ export * from './ports/archive-jobs' export * from './ports/archive-target-folder' export * from './ports/audit' export * from './ports/background-job' +export * from './ports/cache' export * from './ports/cf-hostnames' export * from './ports/changelog' export * from './ports/cloud-store' diff --git a/server/usecases/ports/cache.ts b/server/usecases/ports/cache.ts new file mode 100644 index 00000000..efe841ea --- /dev/null +++ b/server/usecases/ports/cache.ts @@ -0,0 +1,30 @@ +export type CacheMode = 'off' | 'memory' | 'distributed' + +export type CacheTier = 'bypass' | 'memory' | 'distributed' | 'source' + +export interface CachePolicy { + namespace: string + version: number + ttlMs: number + negativeTtlMs?: number + maxEntries: number + validate(value: unknown): value is T +} + +export interface CacheResult { + value: T + tier: CacheTier +} + +export interface DistributedCacheBackend { + get(key: string, cacheTtlSeconds: number): Promise + put(key: string, value: string, expirationTtlSeconds: number): Promise + delete(key: string): Promise +} + +export interface CacheService { + readonly mode: CacheMode + getOrLoad(policy: CachePolicy, key: string, loader: () => Promise): Promise> + replace(policy: CachePolicy, key: string, value: T): Promise + invalidate(policy: CachePolicy, key: string): Promise +} diff --git a/server/usecases/site/auth-provider.ts b/server/usecases/site/auth-provider.ts index 35062c9c..7e6ab898 100644 --- a/server/usecases/site/auth-provider.ts +++ b/server/usecases/site/auth-provider.ts @@ -20,12 +20,21 @@ import { import type { SiteConfig } from '@shared/schemas' import type { AuthProvider } from '@shared/types' import { hasFeature } from '../../domain/licensing' -import { type AppError, badRequest, featureBlocked, type LicenseBindingRepo, type SystemOptionsRepo } from '../ports' +import { + type AppError, + badRequest, + type CacheService, + featureBlocked, + type LicenseBindingRepo, + type SystemOptionsRepo, +} from '../ports' +import { invalidateSiteConfig } from './config-cache' import { loadBindingState } from './licensing' export type AuthProviderDeps = { systemOptions: SystemOptionsRepo licenseBinding: LicenseBindingRepo + cache?: CacheService } function optionKey(providerId: string): string { @@ -158,14 +167,16 @@ export async function upsertAuthProvider( await deps.systemOptions.set(key, value) } + await invalidateSiteConfig(deps) return { ok: true, config: toAuthProvider(config, authOrigin) } } export async function deleteAuthProvider( - deps: Pick, + deps: Pick, providerId: string, ): Promise { if (!isValidProviderId(providerId)) return { ok: false, error: badRequest(INVALID_PROVIDER_ID_MESSAGE) } await deps.systemOptions.delete(optionKey(providerId)) + await invalidateSiteConfig(deps) return { ok: true } } diff --git a/server/usecases/site/branding.ts b/server/usecases/site/branding.ts index 216925a2..56b57896 100644 --- a/server/usecases/site/branding.ts +++ b/server/usecases/site/branding.ts @@ -5,10 +5,12 @@ import { type BrandingThemeMode, isBrandingThemePresetId, } from '@shared/types' -import type { SystemOptionsRepo } from '../ports' +import type { CacheService, SystemOptionsRepo } from '../ports' +import { invalidateSiteConfig } from './config-cache' export type BrandingDeps = { systemOptions: SystemOptionsRepo + cache?: CacheService } const LOGO_MIMES = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'] as const @@ -126,6 +128,7 @@ export async function applyBrandingUpdate( changedFields.push(field) } + if (changedFields.length > 0) await invalidateSiteConfig(deps) return { ok: true, config: await readBranding(deps), changedFields } } @@ -140,6 +143,7 @@ export async function resetBranding(deps: BrandingDeps, params: { field: Brandin } else { await resetBrandingField(deps, field as keyof typeof BRANDING_KEYS) } + await invalidateSiteConfig(deps) } // Encodes the uploaded file as a `data:` URI and stores it in the branding diff --git a/server/usecases/site/config-cache.ts b/server/usecases/site/config-cache.ts new file mode 100644 index 00000000..99979aaa --- /dev/null +++ b/server/usecases/site/config-cache.ts @@ -0,0 +1,22 @@ +import { type SiteConfig, siteConfigSchema } from '@shared/schemas' +import type { CachePolicy, CacheService } from '../ports' + +export const SITE_CONFIG_CACHE_KEY = 'site' + +export const SITE_CONFIG_CACHE_POLICY: CachePolicy = { + namespace: 'site-config', + version: 1, + ttlMs: 60_000, + maxEntries: 1, + validate(value): value is SiteConfig { + return siteConfigSchema.safeParse(value).success + }, +} + +export async function invalidateSiteConfig(deps: { cache?: CacheService }): Promise { + await deps.cache?.invalidate(SITE_CONFIG_CACHE_POLICY, SITE_CONFIG_CACHE_KEY) +} + +export function siteConfigCacheControl(deps: { cache?: CacheService }): string { + return deps.cache?.mode === 'off' ? 'no-store' : 'public, max-age=0, s-maxage=60, must-revalidate' +} diff --git a/server/usecases/site/configz.ts b/server/usecases/site/configz.ts index 6985ed9b..718b9997 100644 --- a/server/usecases/site/configz.ts +++ b/server/usecases/site/configz.ts @@ -10,15 +10,17 @@ import type { SiteBranding, SiteConfig } from '@shared/schemas' import { readCaptchaConfig } from '../../domain/captcha' import { normalizePublicOrigin } from '../../domain/site-public-origin' import { effectiveWebDavUrl } from '../../domain/webdav-public-url' -import type { LicenseBindingRepo, SystemOptionsRepo } from '../ports' +import type { CacheService, LicenseBindingRepo, SystemOptionsRepo } from '../ports' import { listPublicAuthProviders } from './auth-provider' import { readBranding } from './branding' +import { SITE_CONFIG_CACHE_KEY, SITE_CONFIG_CACHE_POLICY } from './config-cache' import { resolveEffectiveSignupMode } from './licensing' -import { SITE_SETTING_KEYS } from './settings' +import { SITE_SETTING_KEYS } from './setting-keys' export type ConfigzDeps = { systemOptions: SystemOptionsRepo licenseBinding: LicenseBindingRepo + cache?: CacheService } const CONFIG_KEYS = [ @@ -59,7 +61,7 @@ function brandingView(config: Awaited>): SiteBra } } -export async function getSiteConfig(deps: ConfigzDeps, requestUrl: string): Promise { +async function loadSiteConfig(deps: ConfigzDeps, requestUrl: string): Promise { const [rows, branding, providers] = await Promise.all([ deps.systemOptions.getMany(CONFIG_KEYS), readBranding(deps), @@ -100,3 +102,10 @@ export async function getSiteConfig(deps: ConfigzDeps, requestUrl: string): Prom }, } } + +export async function getSiteConfig(deps: ConfigzDeps, requestUrl: string): Promise { + if (!deps.cache) return loadSiteConfig(deps, requestUrl) + return ( + await deps.cache.getOrLoad(SITE_CONFIG_CACHE_POLICY, SITE_CONFIG_CACHE_KEY, () => loadSiteConfig(deps, requestUrl)) + ).value +} diff --git a/server/usecases/site/public-origin.test.ts b/server/usecases/site/public-origin.test.ts index 3288ff23..3eb85d2f 100644 --- a/server/usecases/site/public-origin.test.ts +++ b/server/usecases/site/public-origin.test.ts @@ -34,7 +34,10 @@ describe('ensureSitePublicOrigin', () => { await ensureSitePublicOrigin(deps, 'https://pan.example.com/files') // Any DB access would throw on a null handle — the cache must answer. - const result = await ensureSitePublicOrigin({ systemOptions: null as never }, 'https://other.example.com/files') + const result = await ensureSitePublicOrigin( + { cache: deps.cache, systemOptions: null as never }, + 'https://other.example.com/files', + ) expect(result).toEqual({ origin: 'https://pan.example.com', created: false }) }) diff --git a/server/usecases/site/public-origin.ts b/server/usecases/site/public-origin.ts index 8a12c1df..a23a2c14 100644 --- a/server/usecases/site/public-origin.ts +++ b/server/usecases/site/public-origin.ts @@ -1,18 +1,12 @@ -import { normalizePublicOrigin, originFromRequestUrl, SITE_PUBLIC_ORIGIN_KEY } from '../../domain/site-public-origin' -import type { SystemOptionsRepo } from '../ports' - -// Resolved origin, cached for the lifetime of the isolate/process. Only the -// settled value is cached — never a pending promise, which on Cloudflare -// Workers would hang any request that awaited it after its creating request -// ended. One worker serves one site, so a single slot is enough; staleness is -// harmless because the middleware only acts when the row is first created. -let cachedOrigin: string | null = null +import { originFromRequestUrl, SITE_PUBLIC_ORIGIN_KEY } from '../../domain/site-public-origin' +import type { CacheService, SystemOptionsRepo } from '../ports' +import { getSiteRoutingConfig, refreshSiteRoutingConfig } from './routing-config' export function resetSitePublicOriginCache() { - cachedOrigin = null + // Kept for test compatibility. Cache lifetime now belongs to each runtime. } -export type SitePublicOriginDeps = { systemOptions: SystemOptionsRepo } +export type SitePublicOriginDeps = { systemOptions: SystemOptionsRepo; cache?: CacheService } export interface EnsureSitePublicOriginResult { origin: string | null @@ -20,18 +14,15 @@ export interface EnsureSitePublicOriginResult { } export async function getSitePublicOrigin(deps: SitePublicOriginDeps): Promise { - return normalizePublicOrigin(await deps.systemOptions.getValue(SITE_PUBLIC_ORIGIN_KEY)) + return (await getSiteRoutingConfig(deps)).publicOrigin } export async function ensureSitePublicOrigin( deps: SitePublicOriginDeps, requestUrl: string, ): Promise { - if (cachedOrigin) return { origin: cachedOrigin, created: false } - const existing = await getSitePublicOrigin(deps) if (existing) { - cachedOrigin = existing return { origin: existing, created: false } } @@ -42,7 +33,6 @@ export async function ensureSitePublicOrigin( // so the re-read below settles on the persisted value either way. await deps.systemOptions.set(SITE_PUBLIC_ORIGIN_KEY, origin) - const saved = await getSitePublicOrigin(deps) - if (saved) cachedOrigin = saved + const saved = (await refreshSiteRoutingConfig(deps)).publicOrigin return { origin: saved, created: saved === origin } } diff --git a/server/usecases/site/routing-config.ts b/server/usecases/site/routing-config.ts new file mode 100644 index 00000000..9abebd99 --- /dev/null +++ b/server/usecases/site/routing-config.ts @@ -0,0 +1,59 @@ +import { normalizePublicOrigin } from '../../domain/site-public-origin' +import type { CachePolicy, CacheService, SystemOptionsRepo } from '../ports' +import { SITE_SETTING_KEYS } from './setting-keys' + +export interface SiteRoutingConfig { + publicOrigin: string | null + webDavEnabled: boolean + webDavDomain: string +} + +export const SITE_ROUTING_CACHE_KEY = 'site' + +export const SITE_ROUTING_CACHE_POLICY: CachePolicy = { + namespace: 'site-routing', + version: 1, + ttlMs: 60_000, + maxEntries: 1, + validate(value): value is SiteRoutingConfig { + if (typeof value !== 'object' || value === null) return false + const config = value as Partial + return ( + (typeof config.publicOrigin === 'string' || config.publicOrigin === null) && + typeof config.webDavEnabled === 'boolean' && + typeof config.webDavDomain === 'string' + ) + }, +} + +export type SiteRoutingDeps = { + systemOptions: SystemOptionsRepo + cache?: CacheService +} + +async function loadSiteRoutingConfig(deps: Pick): Promise { + const rows = await deps.systemOptions.getMany([ + SITE_SETTING_KEYS.publicOrigin, + SITE_SETTING_KEYS.webdavEnabled, + SITE_SETTING_KEYS.webdavDomain, + ]) + const values = new Map(rows.map((row) => [row.key, row.value])) + return { + publicOrigin: normalizePublicOrigin(values.get(SITE_SETTING_KEYS.publicOrigin)), + webDavEnabled: values.get(SITE_SETTING_KEYS.webdavEnabled) !== 'false', + webDavDomain: values.get(SITE_SETTING_KEYS.webdavDomain)?.trim() ?? '', + } +} + +export async function getSiteRoutingConfig(deps: SiteRoutingDeps): Promise { + if (!deps.cache) return loadSiteRoutingConfig(deps) + return ( + await deps.cache.getOrLoad(SITE_ROUTING_CACHE_POLICY, SITE_ROUTING_CACHE_KEY, () => loadSiteRoutingConfig(deps)) + ).value +} + +export async function refreshSiteRoutingConfig(deps: SiteRoutingDeps): Promise { + const config = await loadSiteRoutingConfig(deps) + await deps.cache?.replace(SITE_ROUTING_CACHE_POLICY, SITE_ROUTING_CACHE_KEY, config) + return config +} diff --git a/server/usecases/site/setting-keys.ts b/server/usecases/site/setting-keys.ts new file mode 100644 index 00000000..cd16fdd6 --- /dev/null +++ b/server/usecases/site/setting-keys.ts @@ -0,0 +1,28 @@ +import { + CAPTCHA_ENABLED_KEY, + CAPTCHA_MIN_SCORE_KEY, + CAPTCHA_PROVIDER_KEY, + CAPTCHA_SECRET_OPTION_KEY, + CAPTCHA_SITE_KEY_KEY, +} from '@shared/captcha' +import { SITE_PUBLIC_ORIGIN_KEY } from '../../domain/site-public-origin' + +export const SITE_SETTING_KEYS = { + name: 'site_name', + description: 'site_description', + publicOrigin: SITE_PUBLIC_ORIGIN_KEY, + signupMode: 'auth_signup_mode', + captchaEnabled: CAPTCHA_ENABLED_KEY, + captchaProvider: CAPTCHA_PROVIDER_KEY, + captchaSiteKey: CAPTCHA_SITE_KEY_KEY, + captchaSecretKey: CAPTCHA_SECRET_OPTION_KEY, + captchaMinScore: CAPTCHA_MIN_SCORE_KEY, + defaultOrgQuota: 'default_org_quota', + defaultTeamQuota: 'default_team_quota', + defaultMonthlyTrafficQuota: 'default_org_monthly_traffic_quota', + webdavVerifiedOrigin: 'webdav_verified_origin', + webdavVerifiedAt: 'webdav_verified_at', + webdavVerificationError: 'webdav_verification_error', + webdavEnabled: 'webdav_enabled', + webdavDomain: 'webdav_domain', +} as const diff --git a/server/usecases/site/settings.ts b/server/usecases/site/settings.ts index 9bf11bfa..0e7db9b4 100644 --- a/server/usecases/site/settings.ts +++ b/server/usecases/site/settings.ts @@ -28,37 +28,22 @@ import type { } from '@shared/schemas' import { readCaptchaConfig } from '../../domain/captcha' import { hasFeature } from '../../domain/licensing' -import { normalizePublicOrigin, SITE_PUBLIC_ORIGIN_KEY } from '../../domain/site-public-origin' +import { normalizePublicOrigin } from '../../domain/site-public-origin' import { WEBDAV_AUTH_CHALLENGE, webDavPathUrl, webDavPublicUrl } from '../../domain/webdav-public-url' import { badRequest, featureBlocked, type LicenseBindingRepo, type SystemOptionsRepo } from '../ports' +import { invalidateSiteConfig } from './config-cache' import { loadBindingState, resolveEffectiveSignupMode } from './licensing' -import { resetSitePublicOriginCache } from './public-origin' +import { getSiteRoutingConfig, refreshSiteRoutingConfig } from './routing-config' +import { SITE_SETTING_KEYS } from './setting-keys' -export const SITE_SETTING_KEYS = { - name: 'site_name', - description: 'site_description', - publicOrigin: SITE_PUBLIC_ORIGIN_KEY, - signupMode: 'auth_signup_mode', - captchaEnabled: CAPTCHA_ENABLED_KEY, - captchaProvider: CAPTCHA_PROVIDER_KEY, - captchaSiteKey: CAPTCHA_SITE_KEY_KEY, - captchaSecretKey: CAPTCHA_SECRET_OPTION_KEY, - captchaMinScore: CAPTCHA_MIN_SCORE_KEY, - defaultOrgQuota: 'default_org_quota', - defaultTeamQuota: 'default_team_quota', - defaultMonthlyTrafficQuota: 'default_org_monthly_traffic_quota', - webdavVerifiedOrigin: 'webdav_verified_origin', - webdavVerifiedAt: 'webdav_verified_at', - webdavVerificationError: 'webdav_verification_error', - webdavEnabled: 'webdav_enabled', - webdavDomain: 'webdav_domain', -} as const +export { SITE_SETTING_KEYS } from './setting-keys' const ALL_SETTING_KEYS = Object.values(SITE_SETTING_KEYS) export type SiteSettingsDeps = { systemOptions: SystemOptionsRepo licenseBinding: LicenseBindingRepo + cache?: import('../ports').CacheService } function optionMap(rows: Array<{ key: string; value: string }>): Map { @@ -150,14 +135,12 @@ function webdavFrom(values: Map, requestUrl: string): SiteWebDav } export async function getSiteWebDavRuntimeConfig( - deps: Pick, + deps: Pick, ): Promise<{ enabled: boolean; domain: string }> { - const values = optionMap( - await deps.systemOptions.getMany([SITE_SETTING_KEYS.webdavEnabled, SITE_SETTING_KEYS.webdavDomain]), - ) + const config = await getSiteRoutingConfig(deps) return { - enabled: isWebDavEnabled(values), - domain: values.get(SITE_SETTING_KEYS.webdavDomain)?.trim() ?? '', + enabled: config.webDavEnabled, + domain: config.webDavDomain, } } @@ -225,12 +208,13 @@ export async function updateSiteIdentity( ] : []), ]) - resetSitePublicOriginCache() + await refreshSiteRoutingConfig(deps) + await invalidateSiteConfig(deps) return { ...input, publicUrl } } export async function verifySiteWebDav( - deps: Pick, + deps: Pick, requestUrl: string, fetcher: typeof fetch, ): Promise { @@ -269,12 +253,13 @@ export async function verifySiteWebDav( { key: SITE_SETTING_KEYS.webdavVerifiedAt, value: verifiedAt }, { key: SITE_SETTING_KEYS.webdavVerificationError, value: error ?? '' }, ]) + await invalidateSiteConfig(deps) const updatedValues = optionMap(await deps.systemOptions.getMany(ALL_SETTING_KEYS)) return webdavFrom(updatedValues, requestUrl) } export async function updateSiteWebDav( - deps: Pick, + deps: Pick, input: UpdateSiteWebDavInput, requestUrl: string, ): Promise { @@ -293,6 +278,8 @@ export async function updateSiteWebDav( ] : []), ]) + await refreshSiteRoutingConfig(deps) + await invalidateSiteConfig(deps) return webdavFrom(optionMap(await deps.systemOptions.getMany(ALL_SETTING_KEYS)), requestUrl) } @@ -310,6 +297,7 @@ export async function updateSiteRegistration( } } await deps.systemOptions.set(SITE_SETTING_KEYS.signupMode, input.mode) + await invalidateSiteConfig(deps) return { configuredMode: input.mode, effectiveMode: await resolveEffectiveSignupMode(deps, input.mode), @@ -336,6 +324,7 @@ export async function updateSiteCaptcha( } await deps.systemOptions.setMany(Object.entries(values).map(([key, value]) => ({ key, value }))) + await invalidateSiteConfig(deps) return captchaFrom(optionMap(Object.entries(values).map(([key, value]) => ({ key, value })))) } diff --git a/server/usecases/site/system.test.ts b/server/usecases/site/system.test.ts index 344bd5be..be40b8dd 100644 --- a/server/usecases/site/system.test.ts +++ b/server/usecases/site/system.test.ts @@ -33,7 +33,9 @@ beforeEach(() => vi.clearAllMocks()) describe('system usecase', () => { describe('resolveInstanceInfo', () => { it('uses the stored site origin when present', async () => { - const deps = makeDeps({ getValue: async () => 'https://files.example.com' }) + const deps = makeDeps({ + getMany: async () => [{ key: 'site_public_origin', value: 'https://files.example.com' }], + }) const info = await resolveInstanceInfo(deps, { requestUrl: 'https://req.example.com/api/system/instance', runtime, diff --git a/workers/bootstrap.cf-test.ts b/workers/bootstrap.cf-test.ts index 33b0cdbc..6faf3727 100644 --- a/workers/bootstrap.cf-test.ts +++ b/workers/bootstrap.cf-test.ts @@ -1,3 +1,4 @@ +import { createExecutionContext, waitOnExecutionContext } from 'cloudflare:test' import { env } from 'cloudflare:workers' import { describe, expect, it } from 'vitest' import worker from './bootstrap' @@ -48,6 +49,23 @@ describe('[CF] Worker fetch handler', () => { const primary = await worker.fetch(new Request('https://pan.example.com/api/health'), testEnv) expect(primary.status).toBe(200) }) + + it('serves public config from the Worker response cache after the first request', async () => { + const request = new Request('https://cache-test.example.com/api/configz') + const firstCtx = createExecutionContext() + const first = await worker.fetch(request, testEnv, firstCtx) + await waitOnExecutionContext(firstCtx) + + expect(first.status).toBe(200) + expect(first.headers.get('x-zpan-cache')).not.toBe('edge') + + const secondCtx = createExecutionContext() + const second = await worker.fetch(request, testEnv, secondCtx) + await waitOnExecutionContext(secondCtx) + + expect(second.status).toBe(200) + expect(second.headers.get('x-zpan-cache')).toBe('edge') + }) }) describe('[CF] SSR share OG meta injection', () => { diff --git a/workers/bootstrap.ts b/workers/bootstrap.ts index fa340144..6fc03ad8 100644 --- a/workers/bootstrap.ts +++ b/workers/bootstrap.ts @@ -1,12 +1,16 @@ +import { createCloudflareKvBackend } from '../server/adapters/cache/cloudflare-kv' +import { createRuntimeCache, resolveCacheMode } from '../server/adapters/cache/runtime-cache' import { createArchiveJobsGateway } from '../server/adapters/gateways/archive-jobs' import { createShareRepo } from '../server/adapters/repos/share' import { createApp } from '../server/app' import type { Auth } from '../server/auth' import { createAuth } from '../server/auth' +import { createDeps } from '../server/composition' import { isPotentialWebDavPublicRequest } from '../server/domain/webdav-public-url' import { createCloudflarePlatform } from '../server/platform/cloudflare' import { platformContext } from '../server/platform/context' -import type { ArchiveJobMessage } from '../server/usecases/ports' +import type { Deps } from '../server/usecases/deps' +import type { ArchiveJobMessage, CacheService } from '../server/usecases/ports' import { DirType } from '../shared/constants' import { handleScheduled } from './scheduled' @@ -16,6 +20,7 @@ interface Env { BETTER_AUTH_URL?: string TRUSTED_ORIGINS?: string ASSETS: Fetcher + CACHE_KV?: KVNamespace [key: string]: unknown } @@ -28,7 +33,69 @@ const SHARE_TOKEN_RE = /^\/s\/([^/?#]+)/ // request on the WebDAV hostname from fixing the primary app base URL without // allowing arbitrary Host headers to grow the cache. Changes to OAuth provider // configs or env vars take effect on isolate recycle. -const cachedAuthBySlot = new Map<'configured' | 'primary' | 'webdav', Auth>() +type AuthSlot = 'configured' | 'primary' | 'webdav' + +interface WorkerRuntime { + platform: ReturnType + deps: Deps + cache: CacheService + authBySlot: Map + appBySlot: Map> +} + +let cachedRuntime: WorkerRuntime | undefined +const responseCache = ( + caches as unknown as { + default: { + match(request: Request): Promise + put(request: Request, response: Response): Promise + } + } +).default + +function runtimeFor(env: Env): WorkerRuntime { + if (cachedRuntime) return cachedRuntime + + const platform = createCloudflarePlatform(env) + const distributed = env.CACHE_KV ? createCloudflareKvBackend(env.CACHE_KV) : undefined + const cache = createRuntimeCache({ + mode: resolveCacheMode(env.ZPAN_CACHE_MODE as string | undefined, !!distributed), + distributed, + }) + cachedRuntime = { + platform, + cache, + deps: createDeps(platform, { cache }), + authBySlot: new Map(), + appBySlot: new Map(), + } + return cachedRuntime +} + +async function appForRequest( + runtime: WorkerRuntime, + request: Request, + env: Env, +): Promise> { + const origin = new URL(request.url).origin + const webDavRequest = isPotentialWebDavPublicRequest(request.url) + const inferredOrigin = origin + const baseURL = env.BETTER_AUTH_URL || inferredOrigin + const trustedOrigins = env.TRUSTED_ORIGINS?.split(',') + .map((value) => value.trim()) + .filter(Boolean) || [inferredOrigin] + const slot: AuthSlot = env.BETTER_AUTH_URL ? 'configured' : webDavRequest ? 'webdav' : 'primary' + + const cachedApp = runtime.appBySlot.get(slot) + const cachedAuth = runtime.authBySlot.get(slot) + if (cachedApp && cachedAuth) return cachedApp + + const auth = await createAuth(runtime.platform, env.BETTER_AUTH_SECRET, baseURL, trustedOrigins) + const app = createApp(runtime.platform, auth, runtime.deps) + runtime.authBySlot.set(slot, auth) + runtime.appBySlot.set(slot, app) + return app +} export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { @@ -36,31 +103,22 @@ export default { if (!BETTER_AUTH_SECRET) { throw new Error('BETTER_AUTH_SECRET is not configured for this deployment.') } - const platform = createCloudflarePlatform(env) - const origin = new URL(request.url).origin - const webDavRequest = isPotentialWebDavPublicRequest(request.url) - const inferredOrigin = origin - const baseURL = env.BETTER_AUTH_URL || inferredOrigin - const trustedOrigins = env.TRUSTED_ORIGINS?.split(',') - .map((o) => o.trim()) - .filter(Boolean) || [inferredOrigin] - const authSlot = env.BETTER_AUTH_URL ? 'configured' : webDavRequest ? 'webdav' : 'primary' + const runtime = runtimeFor(env) + const edgeCached = await matchConfigzResponseCache(request, runtime.cache) + if (edgeCached) return edgeCached + const app = await appForRequest(runtime, request, env) - let auth = cachedAuthBySlot.get(authSlot) - if (!auth) { - auth = await createAuth(platform, BETTER_AUTH_SECRET, baseURL, trustedOrigins) - cachedAuthBySlot.set(authSlot, auth) - } - - return platformContext.run(platform, async () => { + return platformContext.run(runtime.platform, async () => { const url = new URL(request.url) const shareMatch = SHARE_TOKEN_RE.exec(url.pathname) if (shareMatch && request.method === 'GET') { - return handleShareSsr(request, env, ctx, shareMatch[1], platform, auth) + return handleShareSsr(request, env, ctx, shareMatch[1], runtime.platform, app) } - return createApp(platform, auth).fetch(request, env, ctx) + const response = await app.fetch(request, env, ctx) + cacheConfigzResponse(request, response, runtime.cache, ctx) + return response }) }, @@ -78,6 +136,54 @@ export default { }, } +function configzCacheKey(request: Request, includeValidators = false): Request | null { + if (request.method !== 'GET') return null + const url = new URL(request.url) + if (url.pathname !== '/api/configz' && url.pathname !== '/api/configz/') return null + url.search = '' + const ifNoneMatch = request.headers.get('If-None-Match') + return new Request(url.toString(), { + method: 'GET', + headers: includeValidators && ifNoneMatch ? { 'If-None-Match': ifNoneMatch } : undefined, + }) +} + +async function matchConfigzResponseCache(request: Request, cache: CacheService): Promise { + if (cache.mode !== 'distributed') return null + const key = configzCacheKey(request, true) + if (!key) return null + try { + const matched = await responseCache.match(key) + if (!matched) return null + const response = new Response(matched.body, matched) + response.headers.set('X-ZPan-Cache', 'edge') + return response + } catch (error) { + console.error( + JSON.stringify({ + message: 'cache.response.get.error', + error: error instanceof Error ? error.message : String(error), + }), + ) + return null + } +} + +function cacheConfigzResponse(request: Request, response: Response, cache: CacheService, ctx: ExecutionContext): void { + if (cache.mode !== 'distributed' || response.status !== 200) return + const key = configzCacheKey(request) + if (!key) return + const task = responseCache.put(key, response.clone()).catch((error: unknown) => { + console.error( + JSON.stringify({ + message: 'cache.response.put.error', + error: error instanceof Error ? error.message : String(error), + }), + ) + }) + ctx.waitUntil(task) +} + interface ShareMeta { title: string description: string @@ -139,7 +245,7 @@ async function handleShareSsr( ctx: ExecutionContext, token: string, platform: ReturnType, - auth: Auth, + app: ReturnType, ): Promise { const url = new URL(request.url) const origin = url.origin @@ -150,7 +256,7 @@ async function handleShareSsr( ]) if (!spaRes.ok) { - return createApp(platform, auth).fetch(request, env, ctx) + return app.fetch(request, env, ctx) } const html = await spaRes.text() diff --git a/wrangler.toml b/wrangler.toml index 16c30709..5489e481 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -14,6 +14,13 @@ database_name = "zpan-db" database_id = "5bd64957-fa49-4da0-8b7b-6e0ad84037e7" migrations_dir = "./migrations" +[[kv_namespaces]] +binding = "CACHE_KV" +id = "2721f9d7b47d433d8a0e02171e276f57" + +[vars] +ZPAN_CACHE_MODE = "distributed" + [[send_email]] name = "EMAIL" @@ -55,6 +62,7 @@ crons = ["*/10 * * * *", "10 * * * *", "0 */6 * * *", "0 */12 * * *", "0 0 1 * * [env.staging.vars] BETTER_AUTH_URL = "https://zpan-staging.saltbo.workers.dev" ZPAN_CLOUD_URL = "https://zpan-cloud-staging.saltbo.workers.dev" +ZPAN_CACHE_MODE = "distributed" [[env.staging.d1_databases]] binding = "DB" @@ -62,6 +70,10 @@ database_name = "zpan-db-staging" database_id = "a9197d7a-6524-42f0-b646-e714dcb30b3b" migrations_dir = "./migrations" +[[env.staging.kv_namespaces]] +binding = "CACHE_KV" +id = "7d8625bacbc54e23a46ca4464a9f64a0" + [[env.staging.r2_buckets]] binding = "PUBLIC_IMAGES" bucket_name = "zpan-public-images-staging"