init: init proj

This commit is contained in:
2026-08-25 11:35:05 +08:00
commit bf7f2df67e
80 changed files with 9936 additions and 0 deletions

21
shared/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "@proxy-station/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"zod": "^3.24.1"
},
"devDependencies": {
"typescript": "^5.7.2"
}
}

119
shared/src/dto.ts Normal file
View File

@@ -0,0 +1,119 @@
import type { ClientType, Egress, Protocol } from './protocols.js';
export interface NodeDto {
id: string;
name: string;
protocol: Protocol;
server: string;
port: number;
network: 'tcp' | 'ws';
wsPath: string | null;
wsHost: string | null;
tls: boolean;
sni: string | null;
skipCertVerify: boolean;
uuid: string | null;
alterId: number;
security: string;
password: string | null;
method: string | null;
obfsType: string | null;
obfsPassword: string | null;
upMbps: number | null;
downMbps: number | null;
region: string | null;
tags: string[];
enabled: boolean;
sortOrder: number;
createdAt: number;
updatedAt: number;
}
/** 从节点 tags/名称推断的出口类型,仅前端展示用 */
export function nodeEgress(node: Pick<NodeDto, 'tags' | 'name'>): Egress {
const haystack = [...node.tags, node.name].join(' ').toLowerCase();
if (haystack.includes('warp')) return 'warp';
if (haystack.includes('direct')) return 'direct';
return 'unknown';
}
export interface ImportPreviewItem {
ok: boolean;
uri: string;
error?: string;
node?: Partial<NodeDto> & { name: string; protocol: Protocol };
}
export interface GroupMemberDto {
id: number;
kind: 'node' | 'group' | 'builtin';
nodeId: string | null;
refName: string | null;
sortOrder: number;
}
export interface GroupDto {
id: string;
name: string;
type: 'select' | 'url-test' | 'fallback';
testUrl: string | null;
interval: number | null;
tolerance: number | null;
filterRegex: string | null;
includeAllNodes: boolean;
enabled: boolean;
sortOrder: number;
source: 'template' | 'custom';
members: GroupMemberDto[];
}
export interface RuleDto {
id: string;
seq: number;
type: string;
value: string | null;
policy: string;
params: string[];
enabled: boolean;
source: 'template' | 'custom';
rulesetName?: string | null;
rulesetUrl?: string | null;
}
export interface RulesetDto {
id: string;
name: string;
surgeUrl: string;
clashUrl: string | null;
clashBehavior: string;
needsConvert: boolean;
}
export interface TokenDto {
id: string;
token: string;
name: string;
/** 分配给该订阅的节点 id空数组 = 全部节点 */
nodeIds: string[];
enabled: boolean;
createdAt: number;
lastAccessAt: number | null;
accessCount: number;
/** 各客户端订阅路径ShadowRocket 额外有 shadowrocketConf策略组与规则 */
urls: Record<ClientType, string> & { shadowrocketConf: string };
}
export interface ExcludedNode {
name: string;
reason: string;
}
export interface PreviewResult {
client: ClientType;
content: string;
excluded: ExcludedNode[];
}
export interface SettingsDto {
surgeSections: Record<string, string>;
}

3
shared/src/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './protocols.js';
export * from './schemas.js';
export * from './dto.js';

50
shared/src/protocols.ts Normal file
View File

@@ -0,0 +1,50 @@
export const PROTOCOLS = ['vmess', 'trojan', 'shadowsocks', 'hysteria2'] as const;
export type Protocol = (typeof PROTOCOLS)[number];
export const CLIENTS = ['surge', 'shadowrocket', 'clash'] as const;
export type ClientType = (typeof CLIENTS)[number];
export const CLIENT_LABELS: Record<ClientType, string> = {
surge: 'Surge (macOS)',
shadowrocket: 'ShadowRocket (iOS)',
clash: 'ClashMeta (Android)',
};
/**
* ShadowRocket 分两步导入:节点走订阅、策略组与规则走配置文件。
* 配置文件里的策略组用 `use=true` 引用订阅,因此 App 内的订阅名必须与此一致。
*/
export const SHADOWROCKET_SUB_NAME = 'Proxy Station';
export type Egress = 'direct' | 'warp' | 'unknown';
/** Surge 与 Clash 使用 IETF 命名Xray 分享链接使用简名 */
export const SS_CIPHER_ALIASES: Record<string, string> = {
'chacha20-poly1305': 'chacha20-ietf-poly1305',
'xchacha20-poly1305': 'xchacha20-ietf-poly1305',
};
export function normalizeSsCipher(method: string): string {
return SS_CIPHER_ALIASES[method] ?? method;
}
export interface NodeCapabilityInput {
protocol: Protocol;
network: 'tcp' | 'ws';
}
export interface CapabilityVerdict {
ok: boolean;
reason?: string;
}
/**
* 客户端能力矩阵:不支持的组合在下发订阅时自动屏蔽。
* Surge 的 ss 类型没有 WebSocket 传输(仅 simple-obfsSS over WS 线路必须排除。
*/
export function clientSupports(client: ClientType, node: NodeCapabilityInput): CapabilityVerdict {
if (client === 'surge' && node.protocol === 'shadowsocks' && node.network === 'ws') {
return { ok: false, reason: 'Surge 不支持 Shadowsocks over WebSocket' };
}
return { ok: true };
}

99
shared/src/schemas.ts Normal file
View File

@@ -0,0 +1,99 @@
import { z } from 'zod';
import { PROTOCOLS } from './protocols.js';
export const nodeInputSchema = z
.object({
name: z.string().min(1).max(64),
protocol: z.enum(PROTOCOLS),
server: z.string().min(1),
port: z.number().int().min(1).max(65535),
network: z.enum(['tcp', 'ws']).default('tcp'),
wsPath: z.string().startsWith('/').optional().nullable(),
wsHost: z.string().optional().nullable(),
tls: z.boolean().default(false),
sni: z.string().optional().nullable(),
skipCertVerify: z.boolean().default(false),
uuid: z.string().uuid().optional().nullable(),
alterId: z.number().int().min(0).default(0),
security: z.string().default('auto'),
password: z.string().optional().nullable(),
method: z.string().optional().nullable(),
obfsType: z.enum(['salamander']).optional().nullable(),
obfsPassword: z.string().optional().nullable(),
upMbps: z.number().int().positive().optional().nullable(),
downMbps: z.number().int().positive().optional().nullable(),
region: z.string().max(8).optional().nullable(),
tags: z.array(z.string()).default([]),
enabled: z.boolean().default(true),
sortOrder: z.number().int().default(0),
})
.superRefine((v, ctx) => {
if (v.protocol === 'vmess' && !v.uuid) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['uuid'], message: 'VMess 需要 UUID' });
}
if ((v.protocol === 'trojan' || v.protocol === 'shadowsocks' || v.protocol === 'hysteria2') && !v.password) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['password'], message: '该协议需要密码' });
}
if (v.protocol === 'shadowsocks' && !v.method) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['method'], message: 'Shadowsocks 需要加密方式' });
}
if (v.network === 'ws' && !v.wsPath) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['wsPath'], message: 'WebSocket 传输需要路径' });
}
});
export type NodeInput = z.input<typeof nodeInputSchema>;
export type NodeParsed = z.output<typeof nodeInputSchema>;
export const groupInputSchema = z.object({
name: z.string().min(1).max(64),
type: z.enum(['select', 'url-test', 'fallback']),
testUrl: z.string().url().optional().nullable(),
interval: z.number().int().positive().optional().nullable(),
tolerance: z.number().int().positive().optional().nullable(),
filterRegex: z.string().optional().nullable(),
includeAllNodes: z.boolean().default(false),
sortOrder: z.number().int().default(0),
members: z
.array(
z.object({
kind: z.enum(['node', 'group', 'builtin']),
nodeId: z.string().optional().nullable(),
refName: z.string().optional().nullable(),
})
)
.default([]),
});
export type GroupInput = z.input<typeof groupInputSchema>;
export const RULE_TYPES = [
'RULE-SET',
'DOMAIN-SET',
'DOMAIN',
'DOMAIN-SUFFIX',
'DOMAIN-KEYWORD',
'IP-CIDR',
'IP-CIDR6',
'IP-ASN',
'GEOIP',
'PROCESS-NAME',
'USER-AGENT',
'URL-REGEX',
'AND',
'OR',
'NOT',
'FINAL',
] as const;
export const ruleInputSchema = z.object({
type: z.enum(RULE_TYPES),
value: z.string().optional().nullable(),
policy: z.string().min(1),
params: z.array(z.string()).default([]),
enabled: z.boolean().default(true),
});
export type RuleInput = z.input<typeof ruleInputSchema>;
export const tokenInputSchema = z.object({
name: z.string().min(1).max(64),
});

11
shared/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}