mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 00:01:42 +08:00
1d58d5b80d
* feat(image-hosting): add custom domain providers * test(image-hosting): cover custom domain provider flows
187 lines
7.3 KiB
TypeScript
187 lines
7.3 KiB
TypeScript
import { existsSync } from 'node:fs'
|
|
import { release as osRelease } from 'node:os'
|
|
import { serve } from '@hono/node-server'
|
|
import { serveStatic } from '@hono/node-server/serve-static'
|
|
import { Hono } from 'hono'
|
|
import { resolveAppCommit, resolveAppVersion } from '../scripts/app-version.mjs'
|
|
import { ZPAN_CLOUD_URL_DEFAULT } from '../shared/constants'
|
|
import { createQuotaRepo } from './adapters/repos/quota'
|
|
import { createBootstrap } from './bootstrap'
|
|
import { createDeps } from './composition'
|
|
import { createLibsqlPlatform } from './platform/libsql'
|
|
import { createNodePlatform } from './platform/node'
|
|
import { type DeployPlatform, setDeployPlatform } from './runtime-platform'
|
|
import { syncPendingRemoteDownloadUsageReports } from './usecases/downloads/remote-download-usage'
|
|
import { purgeExpiredTrash, resolveTrashRetentionDays } from './usecases/object'
|
|
import { purgeExpiredResourceChanges } from './usecases/resource-changes'
|
|
import { reconcileImageDomains } from './usecases/site/image-domain-provider'
|
|
import { buildCloudInstanceInfo, runtimeInfo } from './usecases/site/instance-info'
|
|
import { INSTANCE_TELEMETRY_CRON, reportInstanceTelemetry } from './usecases/site/instance-telemetry'
|
|
import { runLicensingRefresh } from './usecases/site/licensing'
|
|
import { getSitePublicOrigin } from './usecases/site/public-origin'
|
|
import { syncPendingCloudTrafficReports } from './usecases/store/traffic-metering'
|
|
|
|
const REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000 // 6 hours
|
|
const TRAFFIC_SYNC_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes
|
|
const INSTANCE_TELEMETRY_INTERVAL_MS = 12 * 60 * 60 * 1000 // 12 hours
|
|
const QUOTA_RESET_INTERVAL_MS = 24 * 60 * 60 * 1000 // daily; idempotent, resets only stale periods
|
|
const TRASH_PURGE_INTERVAL_MS = 24 * 60 * 60 * 1000 // daily; purges trash past the retention window
|
|
const STATS_ROLLUP_INTERVAL_MS = 60 * 60 * 1000
|
|
const appVersionGlobalKey = '__ZPAN_APP_VERSION__'
|
|
const appCommitGlobalKey = '__ZPAN_APP_COMMIT__'
|
|
|
|
// tsx runs this entry directly (dev + E2E) without the tsup build-time define,
|
|
// so resolve the version at runtime. In the built output the define inlines the
|
|
// constant, turning this condition into `if (false)`, so resolveAppVersion is
|
|
// never reached and git is never invoked in production. The assignment uses
|
|
// bracket access so the define does not rewrite it into an invalid literal LHS.
|
|
if (!globalThis.__ZPAN_APP_VERSION__) {
|
|
globalThis[appVersionGlobalKey] = resolveAppVersion()
|
|
}
|
|
if (globalThis.__ZPAN_APP_COMMIT__ === undefined) {
|
|
globalThis[appCommitGlobalKey] = resolveAppCommit()
|
|
}
|
|
|
|
// The Node entry serves Cloud Run, Docker, and bare Node. Cloud Run sets
|
|
// K_SERVICE; the Docker image sets ZPAN_RUNTIME=docker (falling back to the
|
|
// /.dockerenv marker); otherwise it is plain Node.
|
|
function detectNodePlatform(): DeployPlatform {
|
|
if (process.env.K_SERVICE) return 'cloud-run'
|
|
if (process.env.ZPAN_RUNTIME === 'docker' || existsSync('/.dockerenv')) return 'docker'
|
|
return 'node'
|
|
}
|
|
setDeployPlatform(detectNodePlatform())
|
|
|
|
const platform = process.env.TURSO_DATABASE_URL
|
|
? await createLibsqlPlatform({
|
|
TURSO_DATABASE_URL: process.env.TURSO_DATABASE_URL,
|
|
TURSO_AUTH_TOKEN: process.env.TURSO_AUTH_TOKEN,
|
|
})
|
|
: createNodePlatform()
|
|
|
|
const deps = createDeps(platform)
|
|
const app = await createBootstrap(platform, deps)
|
|
|
|
const server = new Hono()
|
|
server.route('/', app)
|
|
server.use('/*', serveStatic({ root: './dist' }))
|
|
server.get('/*', serveStatic({ root: './dist', path: 'index.html' }))
|
|
|
|
const port = Number(process.env.PORT) || 8222
|
|
console.log(`ZPan server running on http://localhost:${port}`)
|
|
serve({ fetch: server.fetch, port })
|
|
|
|
// Start licensing refresh background scheduler
|
|
const cloudBaseUrl = process.env.ZPAN_CLOUD_URL ?? ZPAN_CLOUD_URL_DEFAULT
|
|
|
|
function envAllowsIp(value: string | undefined): boolean {
|
|
return !['0', 'false', 'no', 'off'].includes(value?.trim().toLowerCase() ?? '')
|
|
}
|
|
|
|
function isGitHubActionsE2E(): boolean {
|
|
return process.env.GITHUB_ACTIONS === 'true' && process.env.BETTER_AUTH_URL === 'http://localhost:5185'
|
|
}
|
|
|
|
console.log('licensing.refresh.scheduler.started interval=6h')
|
|
setInterval(() => {
|
|
// runLicensingRefresh handles all errors internally and never rejects.
|
|
void (async () => {
|
|
const instanceUrl = await getSitePublicOrigin(deps)
|
|
const instance = instanceUrl
|
|
? await buildCloudInstanceInfo(deps, {
|
|
url: instanceUrl,
|
|
runtime: runtimeInfo(platform),
|
|
})
|
|
: undefined
|
|
await runLicensingRefresh(deps, cloudBaseUrl, instance)
|
|
})()
|
|
}, REFRESH_INTERVAL_MS)
|
|
|
|
console.log('traffic.sync.scheduler.started interval=10m')
|
|
setInterval(() => {
|
|
void syncPendingCloudTrafficReports(deps, { cloudBaseUrl })
|
|
void syncPendingRemoteDownloadUsageReports(deps, { cloudBaseUrl })
|
|
}, TRAFFIC_SYNC_INTERVAL_MS)
|
|
|
|
function reportNodeInstanceTelemetry(): void {
|
|
if (isGitHubActionsE2E()) return
|
|
|
|
void (async () => {
|
|
try {
|
|
await reportInstanceTelemetry(deps, {
|
|
config: {
|
|
allowIp: envAllowsIp(process.env.ZPAN_TELEMETRY_ALLOW_IP),
|
|
},
|
|
cron: INSTANCE_TELEMETRY_CRON,
|
|
trigger: 'runtime',
|
|
runtime: {
|
|
runtime: 'node',
|
|
platform: detectNodePlatform(),
|
|
osPlatform: process.platform,
|
|
osArch: process.arch,
|
|
osRelease: osRelease(),
|
|
nodeVersion: process.version,
|
|
},
|
|
})
|
|
} catch (err) {
|
|
const code = err instanceof Error ? err.message : String(err)
|
|
console.error(`instance.telemetry.error code=${code}`)
|
|
}
|
|
})()
|
|
}
|
|
|
|
console.log('instance.telemetry.scheduler.started interval=12h')
|
|
reportNodeInstanceTelemetry()
|
|
setInterval(reportNodeInstanceTelemetry, INSTANCE_TELEMETRY_INTERVAL_MS)
|
|
|
|
console.log('quota.reset.scheduler.started interval=24h')
|
|
// Run once at boot to catch a month boundary crossed while the server was down.
|
|
void createQuotaRepo(platform.db).resetExpiredTrafficQuotas()
|
|
setInterval(() => {
|
|
void createQuotaRepo(platform.db).resetExpiredTrafficQuotas()
|
|
}, QUOTA_RESET_INTERVAL_MS)
|
|
|
|
console.log('trash.purge.scheduler.started interval=24h')
|
|
function purgeExpiredTrashJob(): void {
|
|
void (async () => {
|
|
try {
|
|
const purged = await purgeExpiredTrash(deps, resolveTrashRetentionDays(process.env.ZPAN_TRASH_RETENTION_DAYS))
|
|
if (purged > 0) console.log(`trash.purge.done count=${purged}`)
|
|
} catch (err) {
|
|
console.error(`trash.purge.error code=${err instanceof Error ? err.message : String(err)}`)
|
|
}
|
|
})()
|
|
}
|
|
purgeExpiredTrashJob()
|
|
setInterval(purgeExpiredTrashJob, TRASH_PURGE_INTERVAL_MS)
|
|
|
|
console.log('stats.rollup.scheduler.started interval=1h')
|
|
function writeStatsRollup(): void {
|
|
void (async () => {
|
|
try {
|
|
await deps.quota.reconcileFreePlanBaselines()
|
|
const now = new Date()
|
|
const [results] = await Promise.all([
|
|
deps.adminStats.refreshHourlyRollups(now),
|
|
deps.webdavState.purgeExpiredLocks(),
|
|
purgeExpiredResourceChanges(deps, now),
|
|
reconcileImageDomains(deps),
|
|
])
|
|
console.log(
|
|
JSON.stringify({
|
|
message: 'stats.rollup.done',
|
|
buckets: results.map((result) => ({
|
|
start: result.bucketStart.toISOString(),
|
|
rows: result.rows,
|
|
lowerBoundRows: result.lowerBoundRows,
|
|
})),
|
|
}),
|
|
)
|
|
} catch (err) {
|
|
console.error(`stats.rollup.error code=${err instanceof Error ? err.message : String(err)}`)
|
|
}
|
|
})()
|
|
}
|
|
writeStatsRollup()
|
|
setInterval(writeStatsRollup, STATS_ROLLUP_INTERVAL_MS)
|