perf(cache): add multi-runtime layered caching

This commit is contained in:
saltbo
2026-07-26 14:15:17 -04:00
parent 3921d9c00d
commit d8222ec757
33 changed files with 940 additions and 112 deletions
+6
View File
@@ -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"
}
}
},
+21
View File
@@ -0,0 +1,21 @@
import type { DistributedCacheBackend } from '../../usecases/ports/cache'
export interface CloudflareKvNamespaceLike {
get(key: string, options: { cacheTtl: number }): Promise<string | null>
put(key: string, value: string, options: { expirationTtl: number }): Promise<void>
delete(key: string): Promise<void>
}
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)
},
}
}
+154
View File
@@ -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<string | null> = {
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<string, string>()
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')
})
})
+185
View File
@@ -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<string, Map<string, MemoryEntry>>()
const now = options.now ?? Date.now
function memoryStore(namespace: string): Map<string, MemoryEntry> {
let store = stores.get(namespace)
if (!store) {
store = new Map()
stores.set(namespace, store)
}
return store
}
function memoryGet<T>(policy: CachePolicy<T>, 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<T>(policy: CachePolicy<T>, value: T): number {
return value === null ? (policy.negativeTtlMs ?? policy.ttlMs) : policy.ttlMs
}
function memoryPut<T>(policy: CachePolicy<T>, 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<T>(policy: CachePolicy<T>, key: string): string {
return `zpan:v${policy.version}:${policy.namespace}:${key}`
}
async function distributedGet<T>(policy: CachePolicy<T>, key: string): Promise<T | undefined> {
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<T>(policy: CachePolicy<T>, key: string, value: T, freshUntil: number): Promise<void> {
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<T>(namespace: string, tier: CacheTier, startedAt: number, value: T): CacheResult<T> {
recordCacheEvent({ namespace, tier, durationMs: performance.now() - startedAt })
return { value, tier }
}
return {
mode: options.mode,
async getOrLoad<T>(policy: CachePolicy<T>, key: string, loader: () => Promise<T>): Promise<CacheResult<T>> {
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<T>(policy: CachePolicy<T>, key: string, value: T): Promise<void> {
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<T>(policy: CachePolicy<T>, key: string): Promise<void> {
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),
}),
)
}
},
}
}
+37 -13
View File
@@ -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<Env>()
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.
+4 -2
View File
@@ -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)
}
+36
View File
@@ -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<CacheEvent[]>()
export function runWithCacheEvents<T>(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(',')
}
+16 -1
View File
@@ -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<CloudflareKvNamespaceLike>('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),
+1 -1
View File
@@ -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)
+25 -4
View File
@@ -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<Env>().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<string> {
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<Env>().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)
})
@@ -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)
@@ -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)
@@ -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' },
})
+8 -9
View File
@@ -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<Env>): 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<Env>, next: Next): Promise<R
if (c.get('webDavMountPath') === '') return next()
const appHosts = getAppHostCandidates(c)
if (appHosts.some((h) => 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(/^\/+/, '')
+2
View File
@@ -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<Env>, start: number): Array<[string, string
['status', status],
['ms', Date.now() - start],
['uid', c.get('userId') ?? '-'],
['cache', cacheLogSummary()],
]
if (c.req.path.startsWith('/dav/')) {
+2
View File
@@ -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
+6
View File
@@ -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)
}
@@ -0,0 +1,40 @@
import type { CachePolicy, CacheService, ImageHostingRepo } from '../ports'
export const IMAGE_DOMAIN_CACHE_POLICY: CachePolicy<string | null> = {
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<string | null> {
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<ImageDomainCacheDeps, 'cache'>,
host: string | null,
): Promise<void> {
if (!host) return
await deps.cache?.invalidate(IMAGE_DOMAIN_CACHE_POLICY, host)
}
export async function cacheVerifiedImageDomain(
deps: Pick<ImageDomainCacheDeps, 'cache'>,
host: string,
orgId: string,
): Promise<void> {
await deps.cache?.replace(IMAGE_DOMAIN_CACHE_POLICY, host, orgId)
}
+1
View File
@@ -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'
+30
View File
@@ -0,0 +1,30 @@
export type CacheMode = 'off' | 'memory' | 'distributed'
export type CacheTier = 'bypass' | 'memory' | 'distributed' | 'source'
export interface CachePolicy<T> {
namespace: string
version: number
ttlMs: number
negativeTtlMs?: number
maxEntries: number
validate(value: unknown): value is T
}
export interface CacheResult<T> {
value: T
tier: CacheTier
}
export interface DistributedCacheBackend {
get(key: string, cacheTtlSeconds: number): Promise<string | null>
put(key: string, value: string, expirationTtlSeconds: number): Promise<void>
delete(key: string): Promise<void>
}
export interface CacheService {
readonly mode: CacheMode
getOrLoad<T>(policy: CachePolicy<T>, key: string, loader: () => Promise<T>): Promise<CacheResult<T>>
replace<T>(policy: CachePolicy<T>, key: string, value: T): Promise<void>
invalidate<T>(policy: CachePolicy<T>, key: string): Promise<void>
}
+13 -2
View File
@@ -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<AuthProviderDeps, 'systemOptions'>,
deps: Pick<AuthProviderDeps, 'systemOptions' | 'cache'>,
providerId: string,
): Promise<DeleteProviderOutcome> {
if (!isValidProviderId(providerId)) return { ok: false, error: badRequest(INVALID_PROVIDER_ID_MESSAGE) }
await deps.systemOptions.delete(optionKey(providerId))
await invalidateSiteConfig(deps)
return { ok: true }
}
+5 -1
View File
@@ -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
+22
View File
@@ -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<SiteConfig> = {
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<void> {
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'
}
+12 -3
View File
@@ -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<ReturnType<typeof readBranding>>): SiteBra
}
}
export async function getSiteConfig(deps: ConfigzDeps, requestUrl: string): Promise<SiteConfig> {
async function loadSiteConfig(deps: ConfigzDeps, requestUrl: string): Promise<SiteConfig> {
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<SiteConfig> {
if (!deps.cache) return loadSiteConfig(deps, requestUrl)
return (
await deps.cache.getOrLoad(SITE_CONFIG_CACHE_POLICY, SITE_CONFIG_CACHE_KEY, () => loadSiteConfig(deps, requestUrl))
).value
}
+4 -1
View File
@@ -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 })
})
+7 -17
View File
@@ -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<string | null> {
return normalizePublicOrigin(await deps.systemOptions.getValue(SITE_PUBLIC_ORIGIN_KEY))
return (await getSiteRoutingConfig(deps)).publicOrigin
}
export async function ensureSitePublicOrigin(
deps: SitePublicOriginDeps,
requestUrl: string,
): Promise<EnsureSitePublicOriginResult> {
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 }
}
+59
View File
@@ -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<SiteRoutingConfig> = {
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<SiteRoutingConfig>
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<SiteRoutingDeps, 'systemOptions'>): Promise<SiteRoutingConfig> {
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<SiteRoutingConfig> {
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<SiteRoutingConfig> {
const config = await loadSiteRoutingConfig(deps)
await deps.cache?.replace(SITE_ROUTING_CACHE_POLICY, SITE_ROUTING_CACHE_KEY, config)
return config
}
+28
View File
@@ -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
+19 -30
View File
@@ -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<string, string> {
@@ -150,14 +135,12 @@ function webdavFrom(values: Map<string, string>, requestUrl: string): SiteWebDav
}
export async function getSiteWebDavRuntimeConfig(
deps: Pick<SiteSettingsDeps, 'systemOptions'>,
deps: Pick<SiteSettingsDeps, 'systemOptions' | 'cache'>,
): 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<SiteSettingsDeps, 'systemOptions'>,
deps: Pick<SiteSettingsDeps, 'systemOptions' | 'cache'>,
requestUrl: string,
fetcher: typeof fetch,
): Promise<SiteWebDavSettings> {
@@ -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<SiteSettingsDeps, 'systemOptions'>,
deps: Pick<SiteSettingsDeps, 'systemOptions' | 'cache'>,
input: UpdateSiteWebDavInput,
requestUrl: string,
): Promise<SiteWebDavSettings> {
@@ -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 }))))
}
+3 -1
View File
@@ -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,
+18
View File
@@ -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', () => {
+128 -22
View File
@@ -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<typeof createCloudflarePlatform>
deps: Deps
cache: CacheService
authBySlot: Map<AuthSlot, Auth>
appBySlot: Map<AuthSlot, ReturnType<typeof createApp>>
}
let cachedRuntime: WorkerRuntime | undefined
const responseCache = (
caches as unknown as {
default: {
match(request: Request): Promise<Response | undefined>
put(request: Request, response: Response): Promise<void>
}
}
).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<ReturnType<typeof createApp>> {
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<Response> {
@@ -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<Response | null> {
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<typeof createCloudflarePlatform>,
auth: Auth,
app: ReturnType<typeof createApp>,
): Promise<Response> {
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()
+12
View File
@@ -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"