1
0

links.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. // Pure parsing/building of Xray share links (vless / vmess / trojan / ss).
  2. // No React/DOM — runs in the browser and in vitest (Node) alike.
  3. import { base64ToText, textToBase64 } from './base64';
  4. export type Protocol = 'vless' | 'vmess' | 'trojan' | 'ss';
  5. export interface ParsedLink {
  6. protocol: Protocol;
  7. /** Remark / fragment label. */
  8. name: string;
  9. address: string;
  10. port: number;
  11. /** UUID (vless/vmess), password (trojan), or `method:password` (ss). */
  12. credential: string;
  13. /** All remaining parameters, for display. */
  14. params: Record<string, string>;
  15. }
  16. export function detectProtocol(link: string): Protocol | null {
  17. const scheme = link.trim().slice(0, link.indexOf('://')).toLowerCase();
  18. if (scheme === 'vless' || scheme === 'vmess' || scheme === 'trojan' || scheme === 'ss') {
  19. return scheme;
  20. }
  21. return null;
  22. }
  23. export function parseLink(link: string): ParsedLink {
  24. const trimmed = link.trim();
  25. const protocol = detectProtocol(trimmed);
  26. switch (protocol) {
  27. case 'vless':
  28. case 'trojan':
  29. return parseUserinfoLink(trimmed, protocol);
  30. case 'vmess':
  31. return parseVmess(trimmed);
  32. case 'ss':
  33. return parseShadowsocks(trimmed);
  34. default:
  35. throw new Error(
  36. `Unsupported or invalid link. Expected vless://, vmess://, trojan://, or ss://`,
  37. );
  38. }
  39. }
  40. // vless:// and trojan:// share the `cred@host:port?params#name` structure.
  41. function parseUserinfoLink(link: string, protocol: 'vless' | 'trojan'): ParsedLink {
  42. const url = new URL(link);
  43. const params: Record<string, string> = {};
  44. url.searchParams.forEach((value, key) => {
  45. params[key] = value;
  46. });
  47. return {
  48. protocol,
  49. name: safeDecode(url.hash.replace(/^#/, '')),
  50. address: stripBrackets(url.hostname),
  51. port: Number(url.port) || 0,
  52. credential: safeDecode(url.username),
  53. params,
  54. };
  55. }
  56. function parseVmess(link: string): ParsedLink {
  57. const payload = link.slice('vmess://'.length);
  58. let obj: Record<string, unknown>;
  59. try {
  60. obj = JSON.parse(base64ToText(payload)) as Record<string, unknown>;
  61. } catch {
  62. throw new Error('Invalid vmess link: payload is not base64-encoded JSON.');
  63. }
  64. const reserved = new Set(['ps', 'add', 'port', 'id', 'v']);
  65. const params: Record<string, string> = {};
  66. for (const [key, value] of Object.entries(obj)) {
  67. if (reserved.has(key) || value === undefined || value === null || value === '') continue;
  68. params[key] = String(value);
  69. }
  70. return {
  71. protocol: 'vmess',
  72. name: String(obj.ps ?? ''),
  73. address: String(obj.add ?? ''),
  74. port: Number(obj.port) || 0,
  75. credential: String(obj.id ?? ''),
  76. params,
  77. };
  78. }
  79. function parseShadowsocks(link: string): ParsedLink {
  80. const rest = link.slice('ss://'.length);
  81. const hashIndex = rest.indexOf('#');
  82. const name = hashIndex >= 0 ? safeDecode(rest.slice(hashIndex + 1)) : '';
  83. const body = hashIndex >= 0 ? rest.slice(0, hashIndex) : rest;
  84. if (body.includes('@')) {
  85. // SIP002: ss://<userinfo>@host:port[/][?plugin=...]
  86. const atIndex = body.lastIndexOf('@');
  87. const userinfo = body.slice(0, atIndex);
  88. const hostPart = body.slice(atIndex + 1);
  89. const credential = userinfo.includes(':') ? safeDecode(userinfo) : tryBase64(userinfo);
  90. const { address, port, params } = parseHostPortQuery(hostPart);
  91. return { protocol: 'ss', name, address, port, credential, params };
  92. }
  93. // Legacy: ss://base64(method:password@host:port)
  94. const decoded = base64ToText(body);
  95. const atIndex = decoded.lastIndexOf('@');
  96. if (atIndex < 0) throw new Error('Invalid ss link: missing host.');
  97. const credential = decoded.slice(0, atIndex);
  98. const { address, port } = parseHostPortQuery(decoded.slice(atIndex + 1));
  99. return { protocol: 'ss', name, address, port, credential, params: {} };
  100. }
  101. function parseHostPortQuery(input: string): {
  102. address: string;
  103. port: number;
  104. params: Record<string, string>;
  105. } {
  106. const queryIndex = input.search(/[/?]/);
  107. const hostPort = queryIndex >= 0 ? input.slice(0, queryIndex) : input;
  108. const query = queryIndex >= 0 ? input.slice(queryIndex).replace(/^\/?\??/, '') : '';
  109. const match = /^(\[[^\]]+\]|[^:]+):(\d+)$/.exec(hostPort);
  110. if (!match) throw new Error('Invalid host:port.');
  111. const params: Record<string, string> = {};
  112. if (query) {
  113. new URLSearchParams(query).forEach((value, key) => {
  114. params[key] = value;
  115. });
  116. }
  117. return { address: stripBrackets(match[1]), port: Number(match[2]), params };
  118. }
  119. // ---- Builders -------------------------------------------------------------
  120. export interface VlessTrojanInput {
  121. credential: string;
  122. address: string;
  123. port: number;
  124. params?: Record<string, string>;
  125. name?: string;
  126. }
  127. export function buildVless(input: VlessTrojanInput): string {
  128. return buildUserinfoLink('vless', input);
  129. }
  130. export function buildTrojan(input: VlessTrojanInput): string {
  131. return buildUserinfoLink('trojan', input);
  132. }
  133. function buildUserinfoLink(scheme: 'vless' | 'trojan', input: VlessTrojanInput): string {
  134. const search = new URLSearchParams(
  135. Object.entries(input.params ?? {}).filter(([, v]) => v !== '' && v != null),
  136. ).toString();
  137. const host = input.address.includes(':') ? `[${input.address}]` : input.address;
  138. const query = search ? `?${search}` : '';
  139. const fragment = input.name ? `#${encodeURIComponent(input.name)}` : '';
  140. return `${scheme}://${encodeURIComponent(input.credential)}@${host}:${input.port}${query}${fragment}`;
  141. }
  142. export function buildVmess(obj: Record<string, string | number>): string {
  143. return `vmess://${textToBase64(JSON.stringify({ v: '2', ...obj }))}`;
  144. }
  145. export interface ShadowsocksInput {
  146. method: string;
  147. password: string;
  148. address: string;
  149. port: number;
  150. name?: string;
  151. }
  152. export function buildShadowsocks(input: ShadowsocksInput): string {
  153. const userinfo = textToBase64(`${input.method}:${input.password}`)
  154. .replace(/\+/g, '-')
  155. .replace(/\//g, '_')
  156. .replace(/=+$/, '');
  157. const host = input.address.includes(':') ? `[${input.address}]` : input.address;
  158. const fragment = input.name ? `#${encodeURIComponent(input.name)}` : '';
  159. return `ss://${userinfo}@${host}:${input.port}${fragment}`;
  160. }
  161. // ---- helpers --------------------------------------------------------------
  162. function stripBrackets(host: string): string {
  163. return host.replace(/^\[/, '').replace(/\]$/, '');
  164. }
  165. function safeDecode(value: string): string {
  166. try {
  167. return decodeURIComponent(value);
  168. } catch {
  169. return value;
  170. }
  171. }
  172. function tryBase64(value: string): string {
  173. try {
  174. const decoded = base64ToText(value);
  175. return decoded.includes(':') ? decoded : value;
  176. } catch {
  177. return value;
  178. }
  179. }