fix(runtime): enforce session and environment contracts

This commit is contained in:
2026-07-16 19:56:15 +08:00
parent acabf165cd
commit cc06ed034d
7 changed files with 444 additions and 203 deletions
@@ -0,0 +1,83 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getApiConfig } from "@/core/net/config/api_config";
const CONFIG_ENV_KEYS = [
"NEXT_PUBLIC_API_BASE_URL",
"NEXT_PUBLIC_WS_BASE_URL",
"NEXT_PUBLIC_API_CONNECT_TIMEOUT",
"NEXT_PUBLIC_API_RECEIVE_TIMEOUT",
"NEXT_PUBLIC_API_SEND_TIMEOUT",
] as const;
afterEach(() => {
vi.unstubAllEnvs();
});
describe("getApiConfig", () => {
it("keeps local defaults in development", () => {
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "development");
unsetConfigEnv();
expect(getApiConfig()).toEqual({
baseUrl: "http://172.16.48.49:3002",
wsUrl: "ws://172.16.48.49:3002/ws",
connectTimeout: 30_000,
receiveTimeout: 60_000,
sendTimeout: 30_000,
});
});
it.each(["test", "production"])(
"requires explicit service URLs in %s",
(appEnv) => {
vi.stubEnv("NEXT_PUBLIC_APP_ENV", appEnv);
unsetConfigEnv();
expect(() => getApiConfig()).toThrow(
`[ApiConfig] Invalid ${appEnv} configuration`,
);
},
);
it("parses an explicit non-development configuration", () => {
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "production");
vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://api.example.test");
vi.stubEnv("NEXT_PUBLIC_WS_BASE_URL", "wss://api.example.test/ws");
vi.stubEnv("NEXT_PUBLIC_API_CONNECT_TIMEOUT", "1000");
vi.stubEnv("NEXT_PUBLIC_API_RECEIVE_TIMEOUT", "2000");
vi.stubEnv("NEXT_PUBLIC_API_SEND_TIMEOUT", "3000");
expect(getApiConfig()).toEqual({
baseUrl: "https://api.example.test",
wsUrl: "wss://api.example.test/ws",
connectTimeout: 1000,
receiveTimeout: 2000,
sendTimeout: 3000,
});
});
it.each(["not-a-number", "0", "-1", "1.5"])(
"rejects invalid timeout %s",
(timeout) => {
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "test");
vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://api.example.test");
vi.stubEnv("NEXT_PUBLIC_WS_BASE_URL", "wss://api.example.test/ws");
vi.stubEnv("NEXT_PUBLIC_API_CONNECT_TIMEOUT", timeout);
expect(() => getApiConfig()).toThrow(/connectTimeout/);
},
);
it("rejects URLs with the wrong protocol", () => {
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "production");
vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "ftp://api.example.test");
vi.stubEnv("NEXT_PUBLIC_WS_BASE_URL", "https://api.example.test/ws");
expect(() => getApiConfig()).toThrow(/baseUrl.*wsUrl/);
});
});
function unsetConfigEnv(): void {
for (const key of CONFIG_ENV_KEYS) vi.stubEnv(key, undefined);
}
+59 -23
View File
@@ -4,6 +4,8 @@
* 通过环境变量配置后端服务地址与超时时间。
*
*/
import { z } from "zod";
import { AppEnvUtil, type AppEnv } from "@/utils/app-env";
export type { AppEnv };
@@ -30,6 +32,29 @@ export class TimeoutConstants {
static readonly longTimeout = 60000;
}
const DEVELOPMENT_API_BASE_URL = "http://172.16.48.49:3002";
const DEVELOPMENT_WS_BASE_URL = "ws://172.16.48.49:3002/ws";
const ApiConfigSchema = z.object({
baseUrl: z
.string()
.trim()
.min(1)
.refine((value) => hasProtocol(value, ["http:", "https:"]), {
message: "must be an http(s) URL",
}),
wsUrl: z
.string()
.trim()
.min(1)
.refine((value) => hasProtocol(value, ["ws:", "wss:"]), {
message: "must be a ws(s) URL",
}),
connectTimeout: z.coerce.number().int().positive(),
receiveTimeout: z.coerce.number().int().positive(),
sendTimeout: z.coerce.number().int().positive(),
});
/**
* 解析当前环境
*/
@@ -41,29 +66,40 @@ export function getAppEnv(): AppEnv {
* 获取当前 API 配置
*/
export function getApiConfig(): ApiConfig {
const baseUrl =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://172.16.48.49:3002";
const wsUrl =
process.env.NEXT_PUBLIC_WS_BASE_URL ?? "ws://172.16.48.49:3002/ws";
const appEnv = getAppEnv();
const allowDevelopmentDefaults = appEnv === "development";
const result = ApiConfigSchema.safeParse({
baseUrl:
process.env.NEXT_PUBLIC_API_BASE_URL ??
(allowDevelopmentDefaults ? DEVELOPMENT_API_BASE_URL : undefined),
wsUrl:
process.env.NEXT_PUBLIC_WS_BASE_URL ??
(allowDevelopmentDefaults ? DEVELOPMENT_WS_BASE_URL : undefined),
connectTimeout:
process.env.NEXT_PUBLIC_API_CONNECT_TIMEOUT ??
TimeoutConstants.shortTimeout,
receiveTimeout:
process.env.NEXT_PUBLIC_API_RECEIVE_TIMEOUT ??
TimeoutConstants.longTimeout,
sendTimeout:
process.env.NEXT_PUBLIC_API_SEND_TIMEOUT ??
TimeoutConstants.shortTimeout,
});
const connectTimeout = Number.parseInt(
process.env.NEXT_PUBLIC_API_CONNECT_TIMEOUT ?? "30000",
10
);
const receiveTimeout = Number.parseInt(
process.env.NEXT_PUBLIC_API_RECEIVE_TIMEOUT ?? "60000",
10
);
const sendTimeout = Number.parseInt(
process.env.NEXT_PUBLIC_API_SEND_TIMEOUT ?? "30000",
10
);
if (!result.success) {
const issues = result.error.issues
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
.join("; ");
throw new Error(`[ApiConfig] Invalid ${appEnv} configuration: ${issues}`);
}
return {
baseUrl,
wsUrl,
connectTimeout,
receiveTimeout,
sendTimeout,
};
return result.data;
}
function hasProtocol(value: string, protocols: readonly string[]): boolean {
try {
return protocols.includes(new URL(value).protocol);
} catch {
return false;
}
}