架构迁移:前后端分离

This commit is contained in:
taojiacheng committed 2026-07-03 17:36:51 +08:00
1 parent 5cc60c55d5
commit 86780f2ce0
25 files changed
+329 -106

No files matched your search

+5 -1
View File
@@ -41,7 +41,10 @@ CRON_SECRET=replace-with-a-random-secret
# Better Auth
BETTER_AUTH_SECRET=replace-with-a-random-secret
BETTER_AUTH_URL=https://daoyou.example.com
BETTER_AUTH_URL=https://api.daoyou.example.com
PUBLIC_WEB_ORIGINS=https://app.daoyou.example.com
# Optional: enable cross-subdomain cookies for app/api subdomains on the same site.
BETTER_AUTH_COOKIE_DOMAIN=.daoyou.example.com
BETTER_AUTH_DB_SCHEMA=better_auth
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
@@ -68,4 +71,5 @@ DISABLE_LLM_NAMING=
# Frontend public build variables
# Configure these in GitHub Actions Environment/Repository vars, not in the
# server runtime env file passed to `docker run --env-file`.
# VITE_API_BASE_URL=https://api.daoyou.example.com
# VITE_TURNSTILE_SITE_KEY=your-public-turnstile-site-key
-2
View File
@@ -33,5 +33,3 @@ jobs:
context: .
push: true
tags: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
build-args: |
VITE_TURNSTILE_SITE_KEY=${{ vars.VITE_TURNSTILE_SITE_KEY }}
+1 -6
View File
@@ -3,18 +3,13 @@
FROM oven/bun:1.3.13 AS builder
WORKDIR /app
ARG VITE_TURNSTILE_SITE_KEY
# 1) 先拷贝依赖描述文件以利用 Docker layer 缓存
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile
# 2) 拷贝源码并构建
COPY . .
RUN if [ -n "$VITE_TURNSTILE_SITE_KEY" ]; then \
export VITE_TURNSTILE_SITE_KEY; \
fi; \
bun run build
RUN bun run build:server
FROM oven/bun:1.3.13 AS runtime
WORKDIR /app
+14 -12
View File
@@ -57,7 +57,7 @@
```text
.
├── src/index.ts # 根 Hono 应用,生产环境负责 SPA fallback
├── src/index.ts # Bun 后端入口,导出 Hono API 与 WebSocket 配置
├── src/server/ # Hono API、认证、服务层、数据库访问
├── src/react-app/ # React SPA
├── src/shared/ # 共享引擎、配置、类型、契约
@@ -75,7 +75,7 @@
- `src/react-app` 使用 `BrowserRouter` 管理前端路由
- `src/server/app.ts` 提供 `/api/*` 和 `/internal/*` 接口
- `src/index.ts` 在生产环境会注册 Bun 内置 cron,用于单容器内直接触发定时任务
- `src/index.ts` 在生产环境对非 API 的 `GET/HEAD` 请求返回 `dist/index.html`,由前端 SPA 接管路由
- 前端 SPA 独立部署到 Cloudflare Pages;后端 Docker 不再服务 `index.html` 或静态资源
当前路由约定:
@@ -83,7 +83,7 @@
- `/api/auth/*`:Better Auth
- `/internal/cron/*`:内部定时任务接口
- `/api/health-check`:健康检查
- 其余如 `/login`、`/game`、`/admin`:前端 SPA 路由
- 其余如 `/login`、`/game`、`/admin`:Cloudflare Pages 上的前端 SPA 路由
## 环境要求
@@ -114,7 +114,7 @@ cp .env.example .env.local
| --- | --- |
| `DATABASE_URL` | PostgreSQL 连接串 |
| `BETTER_AUTH_SECRET` | Better Auth 密钥 |
| `BETTER_AUTH_URL` | Better Auth 对外基准地址;本地开发通常填 `http://localhost:5173`,构建后本地运行通常填 `http://localhost:3000` |
| `BETTER_AUTH_URL` | Better Auth 后端对外基准地址;生产填 API 域名,如 `https://api.example.com` |
### 建议同时配置
@@ -124,6 +124,8 @@ cp .env.example .env.local
| `API_IP_RATE_LIMIT_WINDOW_SECONDS` | `/api/*` 全局 IP 令牌桶补充周期秒数;默认 `60` |
| `API_IP_RATE_LIMIT_MAX_REQUESTS` | `/api/*` 同 IP 令牌桶容量和每周期补充 token 数;默认 `300` |
| `BETTER_AUTH_DB_SCHEMA` | Better Auth schema 名;默认值为 `better_auth` |
| `PUBLIC_WEB_ORIGINS` | 允许访问 API 的前端 origin,逗号分隔,如 `https://app.example.com,http://localhost:5173` |
| `BETTER_AUTH_COOKIE_DOMAIN` | 可选;同站子域部署时可填 `.example.com` 启用跨子域 cookie |
| `ADMIN_EMAILS` | 管理员邮箱白名单,逗号分隔 |
### 生产 cron 必需
@@ -145,6 +147,7 @@ cp .env.example .env.local
| 变量 | 说明 |
| --- | --- |
| `VITE_API_BASE_URL` | 前端构建时注入的后端 API 基地址,如 `https://api.example.com` |
| `VITE_TURNSTILE_SITE_KEY` | 前端构建时注入;没有它,登录/注册/找回密码页不会渲染验证码组件 |
| `TURNSTILE_SECRET_KEY` 或 `TURNSTILE_SECRET` | 服务端校验 Turnstile 的密钥;未配置时服务端仍要求 token,但不会调用 Cloudflare 做真正校验 |
@@ -213,7 +216,9 @@ bun run dev
| 命令 | 作用 |
| --- | --- |
| `bun run dev` | 本地开发 |
| `bun run build` | 构建前端与服务端到 `dist/` |
| `bun run build` | 依次构建前端与服务端 |
| `bun run build:client` | 构建 Cloudflare Pages 使用的前端 SPA |
| `bun run build:server` | 构建 Docker 使用的 Bun/Hono 后端 |
| `bun run preview` | 先构建,再运行 `dist/index.js` |
| `bun run start` | 直接运行已构建产物 |
| `bun run lint` | ESLint 检查 |
@@ -223,8 +228,8 @@ bun run dev
构建产物:
- `dist/index.html`:前端 SPA
- `dist/index.js`:Bun 运行的 Hono 服务入口
- `build:client` 产出前端 SPA
- `build:server` 产出 Bun 运行的 Hono 服务入口 `dist/index.js`
## Docker
@@ -233,9 +238,7 @@ bun run dev
本地构建镜像:
```bash
docker build -t daoyou-hono-bun:local \
--build-arg VITE_TURNSTILE_SITE_KEY=your-public-site-key \
-f Dockerfile .
docker build -t daoyou-hono-bun:local -f Dockerfile .
```
运行镜像:
@@ -248,7 +251,7 @@ docker run --rm -p 3000:3000 \
注意:
- `VITE_TURNSTILE_SITE_KEY` 是前端构建期变量,不是单纯的运行时变量
- `VITE_API_BASE_URL` 和 `VITE_TURNSTILE_SITE_KEY` 是前端 Pages 构建期变量,不进入后端 Docker 镜像
- 服务运行时环境变量通过 shell、容器环境或 `--env-file` 注入
## 仓库内现成部署脚本
@@ -337,7 +340,6 @@ docker compose up -d
- 构建 Docker 镜像
- 推送到 Docker Hub
- 将 `VITE_TURNSTILE_SITE_KEY` 作为构建参数注入
## 贡献指南
+3 -2
View File
@@ -8,8 +8,9 @@
"auth:generate": "bunx auth@latest generate --config src/server/lib/auth/auth.ts --output src/server/lib/auth/better-auth.schema.sql --yes",
"auth:info": "bunx auth@latest info --config src/server/lib/auth/auth.ts --json",
"auth:migrate": "bunx auth@latest migrate --config src/server/lib/auth/auth.ts --yes",
"build": "tsc -b && vite build --mode client && vite build",
"build:client": "tsc -b && vite build --mode client",
"build": "bun run build:client && bun run build:server",
"build:client": "tsc -b tsconfig.app.json && vite build --mode client",
"build:server": "tsc -b tsconfig.node.json && vite build --mode server",
"check": "bun run build",
"dev": "bun scripts/dev.ts",
"dev:api": "bun scripts/dev-api.ts",
+1 -6
View File
@@ -10,13 +10,8 @@ PORT="${PORT:-3000}"
ENV_FILE="${ENV_FILE:-}"
HEALTHCHECK_PATH="${HEALTHCHECK_PATH:-/api/health-check}"
BUILD_ARGS=()
RUN_ARGS=()
if [ -n "${VITE_TURNSTILE_SITE_KEY:-}" ]; then
BUILD_ARGS+=(--build-arg "VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY}")
fi
if [ -n "${ENV_FILE}" ]; then
if [ ! -f "${ENV_FILE}" ]; then
echo "==> ENV_FILE not found: ${ENV_FILE}" >&2
@@ -26,7 +21,7 @@ if [ -n "${ENV_FILE}" ]; then
fi
echo "==> Building image: ${IMAGE_TAG}"
docker build "${BUILD_ARGS[@]}" -t "${IMAGE_TAG}" -f Dockerfile .
docker build -t "${IMAGE_TAG}" -f Dockerfile .
if docker ps -a --format '{{.Names}}' | awk -v name="${CONTAINER_NAME}" '$0 == name { found=1 } END { exit !found }'; then
echo "==> Removing existing container: ${CONTAINER_NAME}"
+3 -68
View File
@@ -1,73 +1,8 @@
import { readFile } from 'node:fs/promises';
import { Hono } from 'hono';
import { logger } from 'hono/logger';
export { websocket } from 'hono/bun';
import apiApp from './server/app';
import app from './server/app';
import { registerInternalCronJobs } from './server/lib/jobs/internalCronScheduler';
const indexHtmlUrl = new URL('../dist/index.html', import.meta.url);
const fileRequestPattern = /\/[^/?]+\.[^/]+$/;
let indexHtmlPromise: Promise<string> | undefined;
const getIndexHtml = () => {
indexHtmlPromise ??= readFile(indexHtmlUrl, 'utf8');
return indexHtmlPromise;
};
const isStaticFileRequest = (path: string) =>
path.startsWith('/assets/') || fileRequestPattern.test(path);
type RootAppOptions = {
enableLogger?: boolean;
isProd?: boolean;
loadIndexHtml?: () => Promise<string>;
};
export function createRootApp(options: RootAppOptions = {}) {
const {
enableLogger = true,
isProd = import.meta.env.PROD,
loadIndexHtml = getIndexHtml,
} = options;
const app = new Hono();
if (enableLogger) {
app.use('/api/*', logger());
app.use('/internal/*', logger());
}
registerInternalCronJobs({ enabled: isProd });
app.route('/', apiApp);
// 生产环境中的文件请求,如果请求的是 API 或内部接口,则返回 404
// 否则返回 index.html,并由前端路由处理
app.notFound(async (c) => {
if (c.req.path.startsWith('/api') || c.req.path.startsWith('/internal/')) {
return Response.json(
{
success: false,
error: 'Not Found',
},
{ status: 404 },
);
}
const isPageRequest = c.req.method === 'GET' || c.req.method === 'HEAD';
if (!isProd || !isPageRequest || isStaticFileRequest(c.req.path)) {
return new Response('Not Found', { status: 404 });
}
return c.html(await loadIndexHtml(), 200, {
'Cache-Control': 'no-cache',
});
});
return app;
}
const app = createRootApp();
registerInternalCronJobs({ enabled: import.meta.env.PROD });
export default app;
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from 'vitest';
async function loadUrlModule(apiBaseUrl?: string) {
vi.resetModules();
vi.doMock('@app/lib/env', () => ({
clientEnv: {
apiBaseUrl,
turnstileSiteKey: undefined,
},
}));
return import('./url');
}
describe('api url helpers', () => {
it('keeps relative api urls when no api base url is configured', async () => {
const { resolveApiUrl } = await loadUrlModule();
expect(resolveApiUrl('/api/player/state')).toBe('/api/player/state');
expect(resolveApiUrl('/assets/app.js')).toBe('/assets/app.js');
});
it('resolves api urls against the configured api base url', async () => {
const { resolveApiUrl } = await loadUrlModule('https://api.example.com');
expect(resolveApiUrl('/api/player/state')).toBe(
'https://api.example.com/api/player/state',
);
});
it('derives websocket urls from the configured api base url', async () => {
const { resolveRealtimeUrl } = await loadUrlModule('https://api.example.com');
expect(resolveRealtimeUrl()).toBe('wss://api.example.com/api/realtime');
});
});
+31
View File
@@ -0,0 +1,31 @@
import { clientEnv } from '@app/lib/env';
export function resolveApiUrl(input: string) {
if (!input.startsWith('/api/')) {
return input;
}
if (!clientEnv.apiBaseUrl) {
return input;
}
return `${clientEnv.apiBaseUrl}${input}`;
}
export function resolveRealtimeUrl() {
if (clientEnv.apiBaseUrl) {
const apiUrl = new URL(clientEnv.apiBaseUrl);
apiUrl.protocol = apiUrl.protocol === 'https:' ? 'wss:' : 'ws:';
apiUrl.pathname = '/api/realtime';
apiUrl.search = '';
apiUrl.hash = '';
return apiUrl.toString();
}
if (typeof window === 'undefined') {
return '/api/realtime';
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/api/realtime`;
}
+5
View File
@@ -1,8 +1,13 @@
import { i18nClient } from '@better-auth/i18n/client';
import { emailOTPClient } from 'better-auth/client/plugins';
import { createAuthClient } from 'better-auth/react';
import { clientEnv } from '@app/lib/env';
export const authClient = createAuthClient({
baseURL: clientEnv.apiBaseUrl,
basePath: '/api/auth',
fetchOptions: {
credentials: 'include',
},
plugins: [i18nClient(), emailOTPClient()],
});
+1
View File
@@ -12,5 +12,6 @@ function getOptionalEnv(...names: string[]): string | undefined {
}
export const clientEnv = {
apiBaseUrl: getOptionalEnv('VITE_API_BASE_URL')?.replace(/\/+$/, ''),
turnstileSiteKey: getOptionalEnv('VITE_TURNSTILE_SITE_KEY'),
};
@@ -2,6 +2,10 @@ import type { RealtimeServerEvent } from '@shared/contracts/realtime';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { RealtimeClient } from './realtimeClient';
vi.mock('@app/lib/api/url', () => ({
resolveRealtimeUrl: () => 'wss://api.example.com/api/realtime',
}));
class MockWebSocket {
static OPEN = 1;
static CONNECTING = 0;
@@ -81,7 +85,7 @@ describe('RealtimeClient', () => {
},
});
expect(socket.url).toBe('ws://localhost:5173/api/realtime');
expect(socket.url).toBe('wss://api.example.com/api/realtime');
expect(socket.sent).toContain(JSON.stringify({ type: 'pong' }));
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({ type: 'world-chat.message' }),
+2 -4
View File
@@ -2,6 +2,7 @@ import type {
RealtimeServerEvent,
RealtimeServerEventType,
} from '@shared/contracts/realtime';
import { resolveRealtimeUrl } from '@app/lib/api/url';
type RealtimeListener<T extends RealtimeServerEventType> = (
event: Extract<RealtimeServerEvent, { type: T }>,
@@ -29,10 +30,7 @@ export class RealtimeClient {
}
this.manuallyClosed = false;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const socket = new WebSocket(
`${protocol}//${window.location.host}/api/realtime`,
);
const socket = new WebSocket(resolveRealtimeUrl());
this.socket = socket;
socket.onopen = () => {
+2 -2
View File
@@ -64,7 +64,7 @@ export async function authLayoutLoader({
try {
const response = await fetch('/api/community/announcement', {
cache: 'no-store',
credentials: 'same-origin',
credentials: 'include',
signal: request.signal,
});
const payload = (await response.json()) as AuthAnnouncementResponse;
@@ -99,7 +99,7 @@ export async function requireAdminLoader({
const response = await fetch('/api/admin/session', {
cache: 'no-store',
credentials: 'same-origin',
credentials: 'include',
signal: request.signal,
});
const payload = (await response.json()) as AdminSessionResponse;
+4
View File
@@ -1,12 +1,14 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router';
import { resolveApiUrl } from './lib/api/url';
import { router } from './router';
import './index.css';
const originalFetch = window.fetch;
window.fetch = async (input, init) => {
if (typeof input === 'string' && input.startsWith('/api/')) {
init = { ...init, credentials: init?.credentials ?? 'include' };
const raw = localStorage.getItem('daoyou_llm_config');
if (raw) {
try {
@@ -22,6 +24,8 @@ window.fetch = async (input, init) => {
// ignore invalid json
}
}
input = resolveApiUrl(input);
}
return originalFetch(input, init);
};
+11
View File
@@ -1,5 +1,8 @@
import { handleAuthRequest } from '@server/lib/auth/hono';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { apiCorsOptions } from '@server/lib/http/cors';
import { runWithContext } from '@server/lib/http/context';
import { apiIpRateLimit } from '@server/lib/hono/apiIpRateLimit';
import { jsonError } from '@server/lib/hono/middleware';
@@ -10,8 +13,16 @@ import internalRouter from '@server/routes/internal';
const app = new Hono<AppEnv>();
app.use('/api/*', logger());
app.use('/internal/*', logger());
app.use('*', async (context, next) => runWithContext(context, next));
app.use(
'/api/*',
cors(apiCorsOptions),
);
app.use('*', async (context, next) => {
const provider = context.req.header('x-llm-provider');
const apiKey = context.req.header('x-llm-api-key');
+18
View File
@@ -3,6 +3,7 @@ import { betterAuth } from 'better-auth';
import { emailOTP } from 'better-auth/plugins/email-otp';
import { sendViaSmtp } from '../admin/smtp';
import { pgPool } from '../drizzle/db';
import { getPublicWebOrigins } from '../http/origins';
function getRequiredEnv(name: 'BETTER_AUTH_SECRET' | 'BETTER_AUTH_URL') {
const value = process.env[name];
@@ -32,6 +33,19 @@ function getGitHubProviderConfig() {
};
}
function getCookieDomainConfig() {
const domain = process.env.BETTER_AUTH_COOKIE_DOMAIN?.trim();
if (!domain) {
return undefined;
}
return {
enabled: true,
domain,
};
}
function fallbackDisplayName(email: string, name?: string | null) {
const trimmedName = name?.trim();
@@ -81,11 +95,15 @@ export const authSchemaName = getBetterAuthSchemaName();
export const auth = betterAuth({
baseURL: getRequiredEnv('BETTER_AUTH_URL'),
secret: getRequiredEnv('BETTER_AUTH_SECRET'),
trustedOrigins: getPublicWebOrigins(),
database: pgPool,
advanced: {
database: {
generateId: 'uuid',
},
...(getCookieDomainConfig()
? { crossSubDomainCookies: getCookieDomainConfig() }
: {}),
},
emailAndPassword: {
enabled: true,
+65
View File
@@ -0,0 +1,65 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { afterEach, describe, expect, it } from 'vitest';
import { apiCorsOptions } from './cors';
const originalOrigins = process.env.PUBLIC_WEB_ORIGINS;
afterEach(() => {
process.env.PUBLIC_WEB_ORIGINS = originalOrigins;
});
function createCorsProbeApp() {
const app = new Hono();
app.use('/api/*', cors(apiCorsOptions));
app.get('/api/probe', (c) => c.json({ success: true }));
return app;
}
describe('api CORS options', () => {
it('allows configured origins with credentials', async () => {
process.env.PUBLIC_WEB_ORIGINS = 'https://app.example.com';
const response = await createCorsProbeApp().request('/api/probe', {
headers: { Origin: 'https://app.example.com' },
});
expect(response.headers.get('access-control-allow-origin')).toBe(
'https://app.example.com',
);
expect(response.headers.get('access-control-allow-credentials')).toBe(
'true',
);
});
it('does not echo unconfigured origins', async () => {
process.env.PUBLIC_WEB_ORIGINS = 'https://app.example.com';
const response = await createCorsProbeApp().request('/api/probe', {
headers: { Origin: 'https://evil.example.com' },
});
expect(response.headers.get('access-control-allow-origin')).toBeNull();
});
it('responds to preflight requests', async () => {
process.env.PUBLIC_WEB_ORIGINS = 'https://app.example.com';
const response = await createCorsProbeApp().request('/api/probe', {
method: 'OPTIONS',
headers: {
Origin: 'https://app.example.com',
'Access-Control-Request-Method': 'POST',
'Access-Control-Request-Headers': 'x-turnstile-token,x-llm-provider',
},
});
expect(response.status).toBe(204);
expect(response.headers.get('access-control-allow-origin')).toBe(
'https://app.example.com',
);
expect(response.headers.get('access-control-allow-credentials')).toBe(
'true',
);
});
});
+18
View File
@@ -0,0 +1,18 @@
import { resolveCorsOrigin } from './origins';
export const apiCorsOptions = {
origin: resolveCorsOrigin,
allowHeaders: [
'Content-Type',
'Authorization',
'x-turnstile-token',
'x-llm-provider',
'x-llm-api-key',
'x-llm-base-url',
'x-llm-model',
'x-llm-fast-model',
],
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
credentials: true,
maxAge: 600,
};
+39
View File
@@ -0,0 +1,39 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
getPublicWebOrigins,
isAllowedPublicWebOrigin,
resolveCorsOrigin,
} from './origins';
const originalOrigins = process.env.PUBLIC_WEB_ORIGINS;
afterEach(() => {
process.env.PUBLIC_WEB_ORIGINS = originalOrigins;
});
describe('public web origins', () => {
it('parses comma-separated origins', () => {
process.env.PUBLIC_WEB_ORIGINS =
'https://app.example.com, http://localhost:5173 ';
expect(getPublicWebOrigins()).toEqual([
'https://app.example.com',
'http://localhost:5173',
]);
});
it('allows missing origin for non-browser probes', () => {
process.env.PUBLIC_WEB_ORIGINS = 'https://app.example.com';
expect(isAllowedPublicWebOrigin(undefined)).toBe(true);
});
it('resolves allowed CORS origins and rejects unknown origins', () => {
process.env.PUBLIC_WEB_ORIGINS = 'https://app.example.com';
expect(resolveCorsOrigin('https://app.example.com')).toBe(
'https://app.example.com',
);
expect(resolveCorsOrigin('https://evil.example.com')).toBe('');
});
});
+26
View File
@@ -0,0 +1,26 @@
function splitOrigins(value: string | undefined) {
if (!value) {
return [];
}
return value
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
}
export function getPublicWebOrigins() {
return splitOrigins(process.env.PUBLIC_WEB_ORIGINS);
}
export function isAllowedPublicWebOrigin(origin: string | undefined | null) {
if (!origin) {
return true;
}
return getPublicWebOrigins().includes(origin);
}
export function resolveCorsOrigin(origin: string) {
return isAllowedPublicWebOrigin(origin) ? origin : '';
}
+5
View File
@@ -0,0 +1,5 @@
import { isAllowedPublicWebOrigin } from './origins';
export function isAllowedRealtimeOrigin(origin: string | undefined | null) {
return isAllowedPublicWebOrigin(origin);
}
@@ -0,0 +1,28 @@
import { afterEach, describe, expect, it } from 'vitest';
import { isAllowedRealtimeOrigin } from '@server/lib/http/realtimeOrigin';
const originalOrigins = process.env.PUBLIC_WEB_ORIGINS;
afterEach(() => {
process.env.PUBLIC_WEB_ORIGINS = originalOrigins;
});
describe('realtime origin guard', () => {
it('allows configured public web origins', () => {
process.env.PUBLIC_WEB_ORIGINS = 'https://app.example.com';
expect(isAllowedRealtimeOrigin('https://app.example.com')).toBe(true);
});
it('rejects browser websocket origins outside the allowlist', () => {
process.env.PUBLIC_WEB_ORIGINS = 'https://app.example.com';
expect(isAllowedRealtimeOrigin('https://evil.example.com')).toBe(false);
});
it('allows missing origin for non-browser probes', () => {
process.env.PUBLIC_WEB_ORIGINS = 'https://app.example.com';
expect(isAllowedRealtimeOrigin(undefined)).toBe(true);
});
});
+5
View File
@@ -1,5 +1,6 @@
import { requireActiveCultivatorRef } from '@server/lib/hono/middleware';
import type { AppEnv } from '@server/lib/hono/types';
import { isAllowedRealtimeOrigin } from '@server/lib/http/realtimeOrigin';
import { subscribePlayerStateEvents } from '@server/lib/services/playerStateBroadcaster';
import { subscribeWorldChatMessages } from '@server/lib/services/worldChatBroadcaster';
import type { RealtimeServerEvent } from '@shared/contracts/realtime';
@@ -18,6 +19,10 @@ function sendEnvelope(ws: WSContext, event: RealtimeServerEvent) {
}
router.get('/', requireActiveCultivatorRef(), async (c) => {
if (!isAllowedRealtimeOrigin(c.req.header('origin'))) {
return c.json({ success: false, error: 'Forbidden origin' }, 403);
}
const ref = c.get('activeCultivatorRef');
if (!ref) {
return c.json({ success: false, error: '当前没有活跃角色' }, 404);
+1 -2
View File
@@ -42,8 +42,7 @@ export default defineConfig(({ command, mode }) => {
plugins: [
build({
entry: './src/index.ts',
staticRoot: './dist',
emptyOutDir: false,
emptyOutDir: true,
entryContentDefaultExportHook: (
appName,
) => `export default websocket !== undefined