mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-08 21:56:08 +00:00
61e12e4c29
* chore(frontend): add husky + lint-staged pre-commit gate Wire a local pre-commit gate that runs eslint --fix on staged frontend TypeScript via lint-staged. Because the only package.json lives in frontend/ while the git root is one level up, the prepare script installs husky hooks at frontend/.husky from the repo root (cd .. && husky frontend/.husky), and the pre-commit hook cd's into frontend/ before invoking lint-staged so node_modules resolves. * test(frontend): add MSW request mocking Add Mock Service Worker so tests can exercise the real http-init.ts request pipeline (CSRF acquisition, 403 refetch-and-retry, body parsing) instead of only stubbing HttpUtil. A node setupServer is started for the vitest unit project with onUnhandledRequest bypass so the existing HttpUtil spies and 55 component tests are untouched; the browser worker is copied to public/ for Storybook and dev use. * chore(frontend): add Storybook + component stories Set up Storybook 10 on the React-Vite builder (compatible with the pinned Vite 8.1.3 and React 19). The preview decorator mirrors the vitest component harness: an Ant Design ConfigProvider with a light/dark toolbar toggle and an en-US i18next instance. main.ts neutralizes the app vite config bits that do not belong in a component workshop (the three-entry rollup input, renderBuiltUrl, and the shared dist outDir) so build-storybook can never clobber internal/web/dist. Seeds stories across the presentational library (viz, ui, clients, feedback). build-storybook is a local tool and is not wired into the CI gate. * feat(frontend): add React Hook Form primitives Introduce the shared RHF layer that AntD inputs bind through, ahead of migrating the forms off Ant Design's Form store: - FormField wraps a Controller in an Ant Design Form.Item shell, reconciling the value/onChange shapes of Input, Switch, InputNumber, Select and friends via normalizeAntdOnChange, with input/output transforms and Zod-issue-key error messages resolved through t(). - useZodForm wires zodResolver (Zod 4) with the AntD-matching modes (validate on submit, then live) and shouldUnregister false so hidden and unmounted-tab fields keep their values. - rhfZodValidate covers the rare per-field rule sites. Covered by a FormField test exercising normalization, transforms, and resolver error surfacing. * refactor(frontend): migrate Pattern-B leaf forms to React Hook Form Move the controlled-useState leaf forms onto RHF via the FormField primitive, keeping Ant Design components and each form's exact submit behaviour (same safeParse, same toast on the first Zod issue, same payload building): - clients: ClientBulkAdjustModal, BulkAddToGroupModal, ClientBulkAddModal - xray: RuleFormModal, BalancerFormModal, WarpModal, NordModal Multi-control widgets that don't fit a single input (inbound dual select, subId regen, expiry branches, the balancer tag warning) stay as explicit Controller/setValue. Derived visibility now reads live values through useWatch. FormField gains a required prop so migrated fields keep their required-asterisk affordance. Settings tabs are intentionally excluded: they are control-panel components that live-patch a parent AllSetting via SettingListItem, not Ant Design Form submit-forms. * refactor(frontend): migrate LoginPage to React Hook Form Replace the Ant Design Form store + antdRule per-field validation with useForm + FormField. The AntD Form stays as the layout/submit wrapper, now driving methods.handleSubmit(onSubmit) via onFinish. Username and password validate through rhfZodValidate(LoginFormSchema.shape.*); the two-factor field keeps its conditional required rule (only registered when 2FA is enabled). Submit posts the same values to /login. * refactor(frontend): migrate ClientFormModal to React Hook Form Move the client add/edit form off controlled useState onto RHF while preserving exact submit behaviour (same ClientFormSchema / ClientCreateFormSchema safeParse, same toast, same payload + attach/ detach diff + external-links build). expiryDate is stored as an epoch number (never a Dayjs) to survive RHF's value cloning, converted at the DateTimePicker boundary. externalLinks uses useFieldArray with stable ids. inboundIds and the derived show*/ss2022 visibility read live via useWatch. Space.Compact button-group widgets stay manual Controllers so the joined borders keep working. * refactor(frontend): migrate Node and DNS modals to React Hook Form Both are self-contained Pattern-A forms (no shared fragments). Replace Form.useForm with useForm + FormProvider, Form.useWatch with useWatch, setFieldValue with setValue, and partial validateFields([...]) with methods.trigger([...]). Per-field antdRule becomes rhfZodValidate rules; the Node scheme->tlsVerify cascade moves to FormField onAfterChange; the DNS domains/expectIPs/unexpectIPs string arrays are driven by useWatch + setValue. Submit runs through handleSubmit on the modal OK button, preserving each form's exact validation, payload build, and save/onConfirm behaviour. * refactor(frontend): migrate HostFormModal to React Hook Form The host external-proxy editor's outer form moves to useForm + FormProvider. Security/tab visibility reads via useWatch; the three json-form editors (HostMuxForm/HostSockoptForm/HostFinalMaskForm) are bound as value/onChange black boxes through a Controller (their own internal forms are unchanged). remark/inboundId keep their validation via rhfZodValidate; submit runs through handleSubmit and builds the same payload (isDisabled = !enable) and save call. * refactor(frontend): migrate OutboundFormModal + fragments to React Hook Form Move the outbound form cluster off Ant Design's Form store onto RHF. The parent uses useForm + FormProvider with a watch() subscription for the protocol reseed cascade and setValue-based network/security/xmux cascades; the JSON<->Basic bridge and the formValuesToWirePayload submit are preserved exactly. Every outbound transport/protocol/security fragment now binds through FormField/useWatch via context. The shared config editors stay untouched and are bound through small value/onChange adapters (src/lib/xray/forms/fields: FinalMaskField, SniffingField, SockoptCustomField) via Controller; HeaderMapEditor binds directly. The host json-form wrappers that reuse the outbound MuxForm/ SockoptForm (HostMuxForm, HostSockoptForm, OutboundSubtreeJsonForm) move to a local RHF provider to match. Outbound render/link tests pass unchanged. * refactor(frontend): migrate InboundFormModal + fragments to React Hook Form Move the inbound add/edit form (the largest form in the panel) and its transport/protocol/security fragments off Ant Design's Form store onto RHF, mirroring the outbound migration. The parent uses useForm + FormProvider with a watch() subscription for the protocol reseed cascade (type==='change' guard so programmatic resets don't reseed) and setValue-based network/security cascades; useSecurityActions drives the TLS/Reality keypair + scan through setValue. Hidden pass-through Form.Items are dropped (their values ride in the reset object and survive via shouldUnregister:false), so getValues() still returns the settings.clients subtree untouched. accounts / certificates / tun lists use useFieldArray; the shared FinalMask/Sniffing/Sockopt editors bind through the value/onChange adapters. Submit keeps the manual InboundFormSchema.safeParse + formatInboundValidation toast + formValuesToWirePayload exactly. The golden link/full fixtures pass byte-for-byte, confirming identical wire output. inbound-form-blocks test harness rewritten from a Form.useForm harness to an RHF provider. * refactor(frontend): retire antdRule; document the RHF form pattern All forms now build on React Hook Form, so the AntD-Form Zod adapter antdRule (src/utils/zodForm.ts) has no remaining callers — remove it. Update frontend/CLAUDE.md: forms use useZodForm + FormField from components/form/rhf with zodResolver/rhfZodValidate validation; AntD <Form> is layout-only; the shared FinalMask/Sniffing/Sockopt editors stay AntD islands wrapped as value/onChange adapters bound via a Controller. * chore(frontend): cover esbuild in the allowScripts allowlist esbuild (pulled in transitively by Vite/Vitest/Storybook) ships a postinstall that npm's allow-scripts flags as uncovered on every install. Its platform binary is delivered through the @esbuild/<platform> optionalDependencies, so the postinstall isn't needed here; deny it like the other entries to silence the warning. * fix(frontend): restore label layout in Sniffing/FinalMask field adapters The value/onChange adapters that wrap the shared SniffingFields and FinalMaskForm editors put them in their own isolated AntD Form, but that Form was missing the label layout the fields used to inherit from the inbound/outbound parent form. Their labels rendered full-width instead of the compact right-aligned column, so the Sniffing tab and the TCP Masks / QUIC Params sections looked broken. Give both adapter forms the same colon=false, labelCol/wrapperCol span 8/14, labelWrap layout. * ci: add least-privilege permissions to Docs CI workflow The docs-ci workflow had no explicit permissions block, so it inherited the repository default for GITHUB_TOKEN. The build job only checks out and builds the docs, so restrict it to contents: read, resolving the CodeQL actions/missing-workflow-permissions alert.
350 lines
8.9 KiB
JavaScript
350 lines
8.9 KiB
JavaScript
/* eslint-disable */
|
|
/* tslint:disable */
|
|
|
|
/**
|
|
* Mock Service Worker.
|
|
* @see https://github.com/mswjs/msw
|
|
* - Please do NOT modify this file.
|
|
*/
|
|
|
|
const PACKAGE_VERSION = '2.14.7'
|
|
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
|
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
|
const activeClientIds = new Set()
|
|
|
|
addEventListener('install', function () {
|
|
self.skipWaiting()
|
|
})
|
|
|
|
addEventListener('activate', function (event) {
|
|
event.waitUntil(self.clients.claim())
|
|
})
|
|
|
|
addEventListener('message', async function (event) {
|
|
const clientId = Reflect.get(event.source || {}, 'id')
|
|
|
|
if (!clientId || !self.clients) {
|
|
return
|
|
}
|
|
|
|
const client = await self.clients.get(clientId)
|
|
|
|
if (!client) {
|
|
return
|
|
}
|
|
|
|
const allClients = await self.clients.matchAll({
|
|
type: 'window',
|
|
})
|
|
|
|
switch (event.data) {
|
|
case 'KEEPALIVE_REQUEST': {
|
|
sendToClient(client, {
|
|
type: 'KEEPALIVE_RESPONSE',
|
|
})
|
|
break
|
|
}
|
|
|
|
case 'INTEGRITY_CHECK_REQUEST': {
|
|
sendToClient(client, {
|
|
type: 'INTEGRITY_CHECK_RESPONSE',
|
|
payload: {
|
|
packageVersion: PACKAGE_VERSION,
|
|
checksum: INTEGRITY_CHECKSUM,
|
|
},
|
|
})
|
|
break
|
|
}
|
|
|
|
case 'MOCK_ACTIVATE': {
|
|
activeClientIds.add(clientId)
|
|
|
|
sendToClient(client, {
|
|
type: 'MOCKING_ENABLED',
|
|
payload: {
|
|
client: {
|
|
id: client.id,
|
|
frameType: client.frameType,
|
|
},
|
|
},
|
|
})
|
|
break
|
|
}
|
|
|
|
case 'CLIENT_CLOSED': {
|
|
activeClientIds.delete(clientId)
|
|
|
|
const remainingClients = allClients.filter((client) => {
|
|
return client.id !== clientId
|
|
})
|
|
|
|
// Unregister itself when there are no more clients
|
|
if (remainingClients.length === 0) {
|
|
self.registration.unregister()
|
|
}
|
|
|
|
break
|
|
}
|
|
}
|
|
})
|
|
|
|
addEventListener('fetch', function (event) {
|
|
const requestInterceptedAt = Date.now()
|
|
|
|
// Bypass navigation requests.
|
|
if (event.request.mode === 'navigate') {
|
|
return
|
|
}
|
|
|
|
// Opening the DevTools triggers the "only-if-cached" request
|
|
// that cannot be handled by the worker. Bypass such requests.
|
|
if (
|
|
event.request.cache === 'only-if-cached' &&
|
|
event.request.mode !== 'same-origin'
|
|
) {
|
|
return
|
|
}
|
|
|
|
// Bypass all requests when there are no active clients.
|
|
// Prevents the self-unregistered worked from handling requests
|
|
// after it's been terminated (still remains active until the next reload).
|
|
if (activeClientIds.size === 0) {
|
|
return
|
|
}
|
|
|
|
const requestId = crypto.randomUUID()
|
|
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
|
})
|
|
|
|
/**
|
|
* @param {FetchEvent} event
|
|
* @param {string} requestId
|
|
* @param {number} requestInterceptedAt
|
|
*/
|
|
async function handleRequest(event, requestId, requestInterceptedAt) {
|
|
const client = await resolveMainClient(event)
|
|
const requestCloneForEvents = event.request.clone()
|
|
const response = await getResponse(
|
|
event,
|
|
client,
|
|
requestId,
|
|
requestInterceptedAt,
|
|
)
|
|
|
|
// Send back the response clone for the "response:*" life-cycle events.
|
|
// Ensure MSW is active and ready to handle the message, otherwise
|
|
// this message will pend indefinitely.
|
|
if (client && activeClientIds.has(client.id)) {
|
|
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
|
|
|
// Clone the response so both the client and the library could consume it.
|
|
const responseClone = response.clone()
|
|
|
|
sendToClient(
|
|
client,
|
|
{
|
|
type: 'RESPONSE',
|
|
payload: {
|
|
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
|
request: {
|
|
id: requestId,
|
|
...serializedRequest,
|
|
},
|
|
response: {
|
|
type: responseClone.type,
|
|
status: responseClone.status,
|
|
statusText: responseClone.statusText,
|
|
headers: Object.fromEntries(responseClone.headers.entries()),
|
|
body: responseClone.body,
|
|
},
|
|
},
|
|
},
|
|
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
|
)
|
|
}
|
|
|
|
return response
|
|
}
|
|
|
|
/**
|
|
* Resolve the main client for the given event.
|
|
* Client that issues a request doesn't necessarily equal the client
|
|
* that registered the worker. It's with the latter the worker should
|
|
* communicate with during the response resolving phase.
|
|
* @param {FetchEvent} event
|
|
* @returns {Promise<Client | undefined>}
|
|
*/
|
|
async function resolveMainClient(event) {
|
|
const client = await self.clients.get(event.clientId)
|
|
|
|
if (activeClientIds.has(event.clientId)) {
|
|
return client
|
|
}
|
|
|
|
if (client?.frameType === 'top-level') {
|
|
return client
|
|
}
|
|
|
|
const allClients = await self.clients.matchAll({
|
|
type: 'window',
|
|
})
|
|
|
|
return allClients
|
|
.filter((client) => {
|
|
// Get only those clients that are currently visible.
|
|
return client.visibilityState === 'visible'
|
|
})
|
|
.find((client) => {
|
|
// Find the client ID that's recorded in the
|
|
// set of clients that have registered the worker.
|
|
return activeClientIds.has(client.id)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* @param {FetchEvent} event
|
|
* @param {Client | undefined} client
|
|
* @param {string} requestId
|
|
* @param {number} requestInterceptedAt
|
|
* @returns {Promise<Response>}
|
|
*/
|
|
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
|
// Clone the request because it might've been already used
|
|
// (i.e. its body has been read and sent to the client).
|
|
const requestClone = event.request.clone()
|
|
|
|
function passthrough() {
|
|
// Cast the request headers to a new Headers instance
|
|
// so the headers can be manipulated with.
|
|
const headers = new Headers(requestClone.headers)
|
|
|
|
// Remove the "accept" header value that marked this request as passthrough.
|
|
// This prevents request alteration and also keeps it compliant with the
|
|
// user-defined CORS policies.
|
|
const acceptHeader = headers.get('accept')
|
|
if (acceptHeader) {
|
|
const values = acceptHeader.split(',').map((value) => value.trim())
|
|
const filteredValues = values.filter(
|
|
(value) => value !== 'msw/passthrough',
|
|
)
|
|
|
|
if (filteredValues.length > 0) {
|
|
headers.set('accept', filteredValues.join(', '))
|
|
} else {
|
|
headers.delete('accept')
|
|
}
|
|
}
|
|
|
|
return fetch(requestClone, { headers })
|
|
}
|
|
|
|
// Bypass mocking when the client is not active.
|
|
if (!client) {
|
|
return passthrough()
|
|
}
|
|
|
|
// Bypass initial page load requests (i.e. static assets).
|
|
// The absence of the immediate/parent client in the map of the active clients
|
|
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
|
// and is not ready to handle requests.
|
|
if (!activeClientIds.has(client.id)) {
|
|
return passthrough()
|
|
}
|
|
|
|
// Notify the client that a request has been intercepted.
|
|
const serializedRequest = await serializeRequest(event.request)
|
|
const clientMessage = await sendToClient(
|
|
client,
|
|
{
|
|
type: 'REQUEST',
|
|
payload: {
|
|
id: requestId,
|
|
interceptedAt: requestInterceptedAt,
|
|
...serializedRequest,
|
|
},
|
|
},
|
|
[serializedRequest.body],
|
|
)
|
|
|
|
switch (clientMessage.type) {
|
|
case 'MOCK_RESPONSE': {
|
|
return respondWithMock(clientMessage.data)
|
|
}
|
|
|
|
case 'PASSTHROUGH': {
|
|
return passthrough()
|
|
}
|
|
}
|
|
|
|
return passthrough()
|
|
}
|
|
|
|
/**
|
|
* @param {Client} client
|
|
* @param {any} message
|
|
* @param {Array<Transferable>} transferrables
|
|
* @returns {Promise<any>}
|
|
*/
|
|
function sendToClient(client, message, transferrables = []) {
|
|
return new Promise((resolve, reject) => {
|
|
const channel = new MessageChannel()
|
|
|
|
channel.port1.onmessage = (event) => {
|
|
if (event.data && event.data.error) {
|
|
return reject(event.data.error)
|
|
}
|
|
|
|
resolve(event.data)
|
|
}
|
|
|
|
client.postMessage(message, [
|
|
channel.port2,
|
|
...transferrables.filter(Boolean),
|
|
])
|
|
})
|
|
}
|
|
|
|
/**
|
|
* @param {Response} response
|
|
* @returns {Response}
|
|
*/
|
|
function respondWithMock(response) {
|
|
// Setting response status code to 0 is a no-op.
|
|
// However, when responding with a "Response.error()", the produced Response
|
|
// instance will have status code set to 0. Since it's not possible to create
|
|
// a Response instance with status code 0, handle that use-case separately.
|
|
if (response.status === 0) {
|
|
return Response.error()
|
|
}
|
|
|
|
const mockedResponse = new Response(response.body, response)
|
|
|
|
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
|
value: true,
|
|
enumerable: true,
|
|
})
|
|
|
|
return mockedResponse
|
|
}
|
|
|
|
/**
|
|
* @param {Request} request
|
|
*/
|
|
async function serializeRequest(request) {
|
|
return {
|
|
url: request.url,
|
|
mode: request.mode,
|
|
method: request.method,
|
|
headers: Object.fromEntries(request.headers.entries()),
|
|
cache: request.cache,
|
|
credentials: request.credentials,
|
|
destination: request.destination,
|
|
integrity: request.integrity,
|
|
redirect: request.redirect,
|
|
referrer: request.referrer,
|
|
referrerPolicy: request.referrerPolicy,
|
|
body: await request.arrayBuffer(),
|
|
keepalive: request.keepalive,
|
|
}
|
|
}
|