mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 00:01:42 +08:00
perf(cache): add multi-runtime layered caching
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Vendored
+36
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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' },
|
||||
})
|
||||
|
||||
@@ -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(/^\/+/, '')
|
||||
|
||||
@@ -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/')) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 }))))
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user