diff --git a/.circleci/config.yml b/.circleci/config.yml index 21f4159d..7a61cbb8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -7,14 +7,15 @@ jobs: steps: - checkout - run: - name: Build web assets (frps) - command: make install build - working_directory: web/frps + name: Test and build web assets + command: make web-ci - run: - name: Build web assets (frpc) - command: make install build - working_directory: web/frpc - - run: make + name: Check Go formatting and build binaries + command: | + set -e + make env fmt + git diff --exit-code + make build - run: make alltest workflows: diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 3217c234..36e8d50b 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -22,12 +22,8 @@ jobs: - uses: actions/setup-node@v6 with: node-version: '22' - - name: Build web assets (frps) - run: make build - working-directory: web/frps - - name: Build web assets (frpc) - run: make build - working-directory: web/frpc + - name: Test and build web assets + run: make web-ci - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: diff --git a/Makefile b/Makefile index bac5601a..a22bce7d 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ NOWEB_TAG = $(shell [ ! -d web/frps/dist ] || [ ! -d web/frpc/dist ] && echo ',n FRP_COMPAT_BASELINE_COUNT ?= 8 FRP_COMPAT_FLOOR_VERSION ?= 0.61.0 -.PHONY: web frps-web frpc-web frps frpc e2e-compatibility-smoke e2e-compatibility e2e-compatibility-floor +.PHONY: web web-ci frps-web frpc-web frps frpc e2e-compatibility-smoke e2e-compatibility e2e-compatibility-floor all: env fmt web build @@ -16,6 +16,9 @@ env: web: frps-web frpc-web +web-ci: + cd web && npm ci && npm run lint:check --workspace frps && npm run lint:check --workspace frpc && npm run test:unit && npm run build --workspace frps && npm run build --workspace frpc + frps-web: $(MAKE) -C web/frps build diff --git a/README.md b/README.md index 8dad3b44..c3bc2f60 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,16 @@ frp is an open source project with its ongoing development made possible entirel

Gold Sponsors

+

+ + +
+ The sovereign cloud that puts you in control +
+ An open source, self-hosted alternative to public clouds, built for data ownership and privacy +
+

+
## Recall.ai - API for meeting recordings @@ -29,16 +39,6 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and The complete IDE crafted for professional Go developers

- -

- - -
- The sovereign cloud that puts you in control -
- An open source, self-hosted alternative to public clouds, built for data ownership and privacy -
-

## What is frp? diff --git a/README_zh.md b/README_zh.md index 713c7b57..600cd4fd 100644 --- a/README_zh.md +++ b/README_zh.md @@ -2,7 +2,6 @@ [![Build Status](https://circleci.com/gh/fatedier/frp.svg?style=shield)](https://circleci.com/gh/fatedier/frp) [![GitHub release](https://img.shields.io/github/tag/fatedier/frp.svg?label=release)](https://github.com/fatedier/frp/releases) -[![Go Report Card](https://goreportcard.com/badge/github.com/fatedier/frp)](https://goreportcard.com/report/github.com/fatedier/frp) [![GitHub Releases Stats](https://img.shields.io/github/downloads/fatedier/frp/total.svg?logo=github)](https://somsubhra.github.io/github-release-stats/?username=fatedier&repository=frp) [README](README.md) | [中文文档](README_zh.md) @@ -15,6 +14,16 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者

Gold Sponsors

+

+ + +
+ The sovereign cloud that puts you in control +
+ An open source, self-hosted alternative to public clouds, built for data ownership and privacy +
+

+
## Recall.ai - API for meeting recordings @@ -32,16 +41,6 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and The complete IDE crafted for professional Go developers

- -

- - -
- The sovereign cloud that puts you in control -
- An open source, self-hosted alternative to public clouds, built for data ownership and privacy -
-

## 为什么使用 frp ? diff --git a/Release.md b/Release.md index 6f209a78..33fc0aea 100644 --- a/Release.md +++ b/Release.md @@ -1,5 +1,9 @@ +## Features + +* UDP packet payloads for ordinary UDP proxies and SUDP now use a dedicated binary codec when frpc and frps successfully negotiate the capability under wire protocol v2, using a more compact wire representation. Wire protocol v1 remains JSON; wire protocol v2 falls back to JSON `UDPPacket` when the peer does not support or did not negotiate the capability. + ## Fixes -* HTTP vhost servers no longer support HTTP/1.1 `Upgrade: h2c` requests. Cleartext HTTP/2 prior-knowledge remains supported. -* Fixed control-session replacement leaks when frpc reconnects through a half-open TCP multiplexed connection. -* Fixed an SSH tunnel gateway panic when handling malformed exec requests. +* Fixed a server panic and remote denial of service caused by a client sending a negative `pool_count`. Negative values are now rejected before work-connection pool resources are allocated. +* Fixed `frpc verify` ignoring configured `featureGates`, which caused VirtualNet configurations to be rejected even when the feature was enabled. +* Fixed a case-insensitive validation bypass that allowed `customDomains` under the configured `subDomainHost` to be registered using mixed-case domain names. diff --git a/client/config_manager_test.go b/client/config_manager_test.go index 07ae3297..2758b2d7 100644 --- a/client/config_manager_test.go +++ b/client/config_manager_test.go @@ -2,12 +2,16 @@ package client import ( "errors" + "os" "path/filepath" + "strings" "testing" "github.com/fatedier/frp/client/configmgmt" "github.com/fatedier/frp/pkg/config/source" v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/fatedier/frp/pkg/policy/security" + "github.com/fatedier/frp/pkg/vnet" ) func newTestRawTCPProxyConfig(name string) *v1.TCPProxyConfig { @@ -22,6 +26,256 @@ func newTestRawTCPProxyConfig(name string) *v1.TCPProxyConfig { } } +func newTestVirtualNetProxyConfig(name string) *v1.STCPProxyConfig { + return &v1.STCPProxyConfig{ + ProxyBaseConfig: v1.ProxyBaseConfig{ + Name: name, + Type: "stcp", + ProxyBackend: v1.ProxyBackend{ + Plugin: v1.TypedClientPluginOptions{ + Type: v1.PluginVirtualNet, + ClientPluginOptions: &v1.VirtualNetPluginOptions{Type: v1.PluginVirtualNet}, + }, + }, + }, + } +} + +func newTestVirtualNetVisitorConfig(name string) *v1.STCPVisitorConfig { + return &v1.STCPVisitorConfig{ + VisitorBaseConfig: v1.VisitorBaseConfig{ + Name: name, + Type: "stcp", + ServerName: "vnet-server", + SecretKey: "secret", + BindPort: -1, + Plugin: v1.TypedVisitorPluginOptions{ + Type: v1.VisitorPluginVirtualNet, + VisitorPluginOptions: &v1.VirtualNetVisitorPluginOptions{ + Type: v1.VisitorPluginVirtualNet, + DestinationIP: "100.86.0.1", + }, + }, + }, + } +} + +func TestServiceConfigManagerReloadVirtualNetRuntimeDependency(t *testing.T) { + const runtimeErr = "VirtualNet-dependent configuration requires a VirtualNet runtime enabled at startup" + + tests := []struct { + name string + startupVirtualNetAddr string + nextConfig string + wantRuntimeDependency bool + }{ + { + name: "unrelated common config", + nextConfig: `serverAddr = "0.0.0.0"`, + }, + { + name: "VirtualNet address without startup runtime", + nextConfig: `featureGates = { VirtualNet = true } +virtualNet.address = "100.86.0.4/24" +`, + wantRuntimeDependency: true, + }, + { + name: "VirtualNet proxy without startup runtime", + nextConfig: `[[proxies]] +name = "vnet-proxy" +type = "stcp" +secretKey = "secret" +[proxies.plugin] +type = "virtual_net" +`, + wantRuntimeDependency: true, + }, + { + name: "VirtualNet visitor without startup runtime", + nextConfig: `[[visitors]] +name = "vnet-visitor" +type = "stcp" +serverName = "vnet-server" +secretKey = "secret" +bindPort = -1 +[visitors.plugin] +type = "virtual_net" +destinationIP = "100.86.0.1" +`, + wantRuntimeDependency: true, + }, + { + name: "existing VirtualNet startup runtime", + startupVirtualNetAddr: "100.86.0.4/24", + nextConfig: `featureGates = { VirtualNet = true } +virtualNet.address = "100.86.0.5/24" +`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + current := &v1.ClientCommonConfig{} + if tc.startupVirtualNetAddr != "" { + current.FeatureGates = map[string]bool{"VirtualNet": true} + current.VirtualNet.Address = tc.startupVirtualNetAddr + } + if err := current.Complete(); err != nil { + t.Fatalf("complete current config: %v", err) + } + + configFile := filepath.Join(t.TempDir(), "frpc.toml") + if err := os.WriteFile(configFile, []byte(tc.nextConfig), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + configSource := source.NewConfigSource() + aggregator := source.NewAggregator(configSource) + svr := &Service{ + common: current, + reloadCommon: current, + configFilePath: configFile, + unsafeFeatures: security.NewUnsafeFeatures(nil), + aggregator: aggregator, + configSource: configSource, + } + if tc.startupVirtualNetAddr != "" { + svr.vnetController = vnet.NewController(current.VirtualNet) + } + + err := (&serviceConfigManager{svr: svr}).ReloadFromFile(true) + if tc.wantRuntimeDependency { + if !errors.Is(err, configmgmt.ErrApplyConfig) || !strings.Contains(err.Error(), runtimeErr) { + t.Fatalf("expected VirtualNet runtime dependency error, got: %v", err) + } + return + } + + if err != nil { + t.Fatalf("reload config: %v", err) + } + if svr.common != current { + t.Fatal("reload should not replace startup common config") + } + if tc.startupVirtualNetAddr == "" && svr.vnetController != nil { + t.Fatal("reload should not enable startup-only VirtualNet runtime state") + } + }) + } +} + +func TestServiceConfigManagerReloadVirtualNetRuntimeDependencyUsesMergedSources(t *testing.T) { + const runtimeErr = "VirtualNet-dependent configuration requires a VirtualNet runtime enabled at startup" + + tests := []struct { + name string + nextConfig string + storeProxy v1.ProxyConfigurer + storeVisitor v1.VisitorConfigurer + wantRuntimeDependency bool + wantProxyPlugin string + }{ + { + name: "Store VirtualNet proxy is rejected", + nextConfig: `serverAddr = "0.0.0.0"`, + storeProxy: newTestVirtualNetProxyConfig("store-vnet"), + wantRuntimeDependency: true, + }, + { + name: "Store VirtualNet visitor is rejected", + nextConfig: `serverAddr = "0.0.0.0"`, + storeVisitor: newTestVirtualNetVisitorConfig("store-vnet"), + wantRuntimeDependency: true, + }, + { + name: "Store VirtualNet proxy overrides file proxy", + nextConfig: `[[proxies]] +name = "shared" +type = "tcp" +localPort = 10080 +remotePort = 10081 +`, + storeProxy: newTestVirtualNetProxyConfig("shared"), + wantRuntimeDependency: true, + }, + { + name: "Store non-VirtualNet proxy overrides file VirtualNet proxy", + nextConfig: `[[proxies]] +name = "shared" +type = "stcp" +secretKey = "secret" +[proxies.plugin] +type = "virtual_net" +`, + storeProxy: newTestRawTCPProxyConfig("shared"), + wantProxyPlugin: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + current := &v1.ClientCommonConfig{} + if err := current.Complete(); err != nil { + t.Fatalf("complete current config: %v", err) + } + + configFile := filepath.Join(t.TempDir(), "frpc.toml") + if err := os.WriteFile(configFile, []byte(tc.nextConfig), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + storeSource, err := source.NewStoreSource(source.StoreSourceConfig{ + Path: filepath.Join(t.TempDir(), "store.json"), + }) + if err != nil { + t.Fatalf("new store source: %v", err) + } + if tc.storeProxy != nil { + if err := storeSource.AddProxy(tc.storeProxy); err != nil { + t.Fatalf("add store proxy: %v", err) + } + } + if tc.storeVisitor != nil { + if err := storeSource.AddVisitor(tc.storeVisitor); err != nil { + t.Fatalf("add store visitor: %v", err) + } + } + + configSource := source.NewConfigSource() + aggregator := source.NewAggregator(configSource) + aggregator.SetStoreSource(storeSource) + svr := &Service{ + common: current, + reloadCommon: current, + configFilePath: configFile, + unsafeFeatures: security.NewUnsafeFeatures(nil), + aggregator: aggregator, + configSource: configSource, + storeSource: storeSource, + } + + err = (&serviceConfigManager{svr: svr}).ReloadFromFile(true) + if tc.wantRuntimeDependency { + if !errors.Is(err, configmgmt.ErrApplyConfig) || !strings.Contains(err.Error(), runtimeErr) { + t.Fatalf("expected VirtualNet runtime dependency error, got: %v", err) + } + return + } + if err != nil { + t.Fatalf("reload config: %v", err) + } + + if len(svr.proxyCfgs) != 1 { + t.Fatalf("expected one applied proxy, got %d", len(svr.proxyCfgs)) + } + if got := svr.proxyCfgs[0].GetBaseConfig().Plugin.Type; got != tc.wantProxyPlugin { + t.Fatalf("unexpected applied proxy plugin: %q", got) + } + }) + } +} + func TestServiceConfigManagerCreateStoreProxyConflict(t *testing.T) { storeSource, err := source.NewStoreSource(source.StoreSourceConfig{ Path: filepath.Join(t.TempDir(), "store.json"), diff --git a/client/control.go b/client/control.go index 6e3002bc..4a354ff6 100644 --- a/client/control.go +++ b/client/control.go @@ -47,6 +47,8 @@ type SessionContext struct { Connector MessageConnector // Virtual net controller VnetController *vnet.Controller + // UDPPacketCodec is immutable for the lifetime of this negotiated session. + UDPPacketCodec string } type Control struct { @@ -92,9 +94,16 @@ func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, erro ctl.registerMsgHandlers() ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher) - ctl.pm = proxy.NewManager(ctl.ctx, sessionCtx.Common, sessionCtx.Auth.EncryptionKey(), ctl.msgTransporter, sessionCtx.VnetController) + ctl.pm = proxy.NewManager( + ctl.ctx, + sessionCtx.Common, + sessionCtx.Auth.EncryptionKey(), + ctl.msgTransporter, + sessionCtx.VnetController, + sessionCtx.UDPPacketCodec, + ) ctl.vm = visitor.NewManager(ctl.ctx, sessionCtx.RunID, sessionCtx.Common, - ctl.connectServer, ctl.msgTransporter, sessionCtx.VnetController) + ctl.connectServer, ctl.msgTransporter, sessionCtx.VnetController, sessionCtx.UDPPacketCodec) return ctl, nil } diff --git a/client/control_session.go b/client/control_session.go index d533ba2d..e3ed27fc 100644 --- a/client/control_session.go +++ b/client/control_session.go @@ -99,6 +99,7 @@ func (d *controlSessionDialer) Dial(previousRunID string) (*SessionContext, erro Auth: d.auth, Connector: newMessageConnector(connector, d.common.Transport.WireProtocol), VnetController: d.vnetController, + UDPPacketCodec: loginResult.udpPacketCodec, }, nil } @@ -127,8 +128,9 @@ func (d *controlSessionDialer) buildLoginMsg(previousRunID string) (*msg.Login, } type loginExchangeResult struct { - resp *msg.LoginResp - crypto *wire.CryptoContext + resp *msg.LoginResp + crypto *wire.CryptoContext + udpPacketCodec string } func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login) (*loginExchangeResult, error) { @@ -172,6 +174,7 @@ func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login) }() var cryptoContext *wire.CryptoContext + var udpPacketCodec string if wireConn != nil { serverHelloFrame, err := wireConn.ReadFrame() if err != nil { @@ -191,6 +194,7 @@ func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login) if err != nil { return nil, err } + udpPacketCodec = serverHello.Selected.Message.UDPPacketCodec } var loginRespMsg msg.LoginResp @@ -198,8 +202,9 @@ func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login) return nil, err } return &loginExchangeResult{ - resp: &loginRespMsg, - crypto: cryptoContext, + resp: &loginRespMsg, + crypto: cryptoContext, + udpPacketCodec: udpPacketCodec, }, nil } diff --git a/client/control_session_test.go b/client/control_session_test.go index a0778fba..4bbcd453 100644 --- a/client/control_session_test.go +++ b/client/control_session_test.go @@ -117,6 +117,7 @@ func TestControlSessionDialerDialV1(t *testing.T) { defer sessionCtx.Connector.Close() require.Equal(t, "run-v1", sessionCtx.RunID) + require.Empty(t, sessionCtx.UDPPacketCodec) require.NotNil(t, sessionCtx.Conn) require.NotNil(t, sessionCtx.Connector) require.False(t, connector.closed.Load()) @@ -225,6 +226,7 @@ func TestControlSessionDialerDialV2(t *testing.T) { defer sessionCtx.Connector.Close() require.Equal(t, "run-v2", sessionCtx.RunID) + require.Equal(t, wire.UDPPacketCodecBinary, sessionCtx.UDPPacketCodec) require.NotNil(t, sessionCtx.Conn) require.NotNil(t, sessionCtx.Connector) require.False(t, connector.closed.Load()) diff --git a/client/control_udp_test.go b/client/control_udp_test.go new file mode 100644 index 00000000..f3c91232 --- /dev/null +++ b/client/control_udp_test.go @@ -0,0 +1,125 @@ +// Copyright 2026 The frp Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !frps + +package client + +import ( + "context" + "encoding/binary" + "net" + "testing" + "time" + + "github.com/stretchr/testify/require" + + clientproxy "github.com/fatedier/frp/client/proxy" + "github.com/fatedier/frp/pkg/auth" + v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/fatedier/frp/pkg/msg" + "github.com/fatedier/frp/pkg/proto/wire" +) + +func TestControlPropagatesBinaryUDPPacketCodecToWorkConn(t *testing.T) { + echoConn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")}) + require.NoError(t, err) + t.Cleanup(func() { _ = echoConn.Close() }) + + echoDone := make(chan error, 1) + go func() { + buf := make([]byte, 64) + n, addr, err := echoConn.ReadFromUDP(buf) + if err == nil { + _, err = echoConn.WriteToUDP(buf[:n], addr) + } + echoDone <- err + }() + + authRuntime, err := auth.BuildClientAuth(&v1.AuthClientConfig{ + Method: v1.AuthMethodToken, + Token: "token", + }) + require.NoError(t, err) + + controlConn, controlPeer := net.Pipe() + t.Cleanup(func() { + _ = controlConn.Close() + _ = controlPeer.Close() + }) + common := &v1.ClientCommonConfig{ + Transport: v1.ClientTransportConfig{WireProtocol: wire.ProtocolV2}, + UDPPacketSize: 1500, + } + ctl, err := NewControl(context.Background(), &SessionContext{ + Common: common, + RunID: "binary-udp-test", + Conn: msg.NewConn(controlConn, msg.NewV2ReadWriter(controlConn)), + Auth: authRuntime, + UDPPacketCodec: wire.UDPPacketCodecBinary, + }) + require.NoError(t, err) + t.Cleanup(ctl.pm.Close) + + echoAddr := echoConn.LocalAddr().(*net.UDPAddr) + proxyCfg := &v1.UDPProxyConfig{ + ProxyBaseConfig: v1.ProxyBaseConfig{ + Name: "udp", + Type: string(v1.ProxyTypeUDP), + ProxyBackend: v1.ProxyBackend{ + LocalIP: "127.0.0.1", + LocalPort: echoAddr.Port, + }, + }, + } + ctl.pm.UpdateAll([]v1.ProxyConfigurer{proxyCfg}) + require.Eventually(t, func() bool { + status, ok := ctl.pm.GetProxyStatus("udp") + return ok && status.Phase == clientproxy.ProxyPhaseWaitStart + }, time.Second, 10*time.Millisecond) + require.NoError(t, ctl.pm.StartProxy("udp", "", "")) + + workClient, workServer := net.Pipe() + t.Cleanup(func() { + _ = workClient.Close() + _ = workServer.Close() + }) + deadline := time.Now().Add(3 * time.Second) + require.NoError(t, workClient.SetDeadline(deadline)) + require.NoError(t, workServer.SetDeadline(deadline)) + ctl.pm.HandleWorkConn("udp", workClient, &msg.StartWorkConn{ProxyName: "udp"}) + + serverRW, err := msg.NewUDPPacketReadWriter(workServer, wire.ProtocolV2, wire.UDPPacketCodecBinary) + require.NoError(t, err) + writeDone := make(chan error, 1) + in := &msg.UDPPacket{ + Content: []byte("binary udp"), + RemoteAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 12345}, + } + go func() { + writeDone <- serverRW.WriteMsg(in) + }() + + frame, err := wire.NewConn(workServer).ReadFrame() + require.NoError(t, err) + require.Equal(t, wire.FrameTypeMessage, frame.Type) + require.GreaterOrEqual(t, len(frame.Payload), 2) + require.Equal(t, msg.V2TypeUDPPacketBinary, binary.BigEndian.Uint16(frame.Payload[:2])) + out, err := msg.DecodeUDPPacketBinary(frame.Payload[2:]) + require.NoError(t, err) + require.Equal(t, in.Content, out.Content) + require.Equal(t, in.RemoteAddr.String(), out.RemoteAddr.String()) + require.NoError(t, <-writeDone) + require.NoError(t, <-echoDone) +} diff --git a/client/proxy/proxy.go b/client/proxy/proxy.go index 84ff49a1..e982c03b 100644 --- a/client/proxy/proxy.go +++ b/client/proxy/proxy.go @@ -61,11 +61,12 @@ func NewProxy( encryptionKey []byte, msgTransporter transport.MessageTransporter, vnetController *vnet.Controller, + udpPacketCodec string, ) (pxy Proxy) { var limiter *rate.Limiter limitBytes := pxyConf.GetBaseConfig().Transport.BandwidthLimit.Bytes() if limitBytes > 0 && pxyConf.GetBaseConfig().Transport.BandwidthLimitMode == types.BandwidthLimitModeClient { - limiter = rate.NewLimiter(rate.Limit(float64(limitBytes)), int(limitBytes)) + limiter = limit.NewBandwidthLimiter(limitBytes) } baseProxy := BaseProxy{ @@ -77,6 +78,7 @@ func NewProxy( vnetController: vnetController, xl: xlog.FromContextSafe(ctx), ctx: ctx, + udpPacketCodec: udpPacketCodec, } factory := proxyFactoryRegistry[reflect.TypeOf(pxyConf)] @@ -98,9 +100,10 @@ type BaseProxy struct { proxyPlugin plugin.Plugin inWorkConnCallback func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) /* continue */ bool - mu sync.RWMutex - xl *xlog.Logger - ctx context.Context + mu sync.RWMutex + xl *xlog.Logger + ctx context.Context + udpPacketCodec string } func (pxy *BaseProxy) Run() error { @@ -171,6 +174,26 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor xl.Tracef("handle tcp work connection, useEncryption: %t, useCompression: %t", baseCfg.Transport.UseEncryption, baseCfg.Transport.UseCompression) + var srcAddr, dstAddr *net.TCPAddr + if m.SrcAddr != "" && m.SrcPort != 0 { + if m.DstAddr == "" { + m.DstAddr = "127.0.0.1" + } + var err error + srcAddr, err = net.ResolveTCPAddr("tcp", net.JoinHostPort(m.SrcAddr, strconv.Itoa(int(m.SrcPort)))) + if err != nil { + xl.Warnf("resolve source address [%s] error: %v", m.SrcAddr, err) + _ = workConn.Close() + return + } + dstAddr, err = net.ResolveTCPAddr("tcp", net.JoinHostPort(m.DstAddr, strconv.Itoa(int(m.DstPort)))) + if err != nil { + xl.Warnf("resolve destination address [%s] error: %v", m.DstAddr, err) + _ = workConn.Close() + return + } + } + remote, recycleFn, err := pxy.wrapWorkConn(workConn, encKey) if err != nil { xl.Errorf("wrap work connection: %v", err) @@ -180,11 +203,6 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor // check if we need to send proxy protocol info var connInfo plugin.ConnectionInfo if m.SrcAddr != "" && m.SrcPort != 0 { - if m.DstAddr == "" { - m.DstAddr = "127.0.0.1" - } - srcAddr, _ := net.ResolveTCPAddr("tcp", net.JoinHostPort(m.SrcAddr, strconv.Itoa(int(m.SrcPort)))) - dstAddr, _ := net.ResolveTCPAddr("tcp", net.JoinHostPort(m.DstAddr, strconv.Itoa(int(m.DstPort)))) connInfo.SrcAddr = srcAddr connInfo.DstAddr = dstAddr } diff --git a/client/proxy/proxy_manager.go b/client/proxy/proxy_manager.go index 42f9f589..6bf8d4a8 100644 --- a/client/proxy/proxy_manager.go +++ b/client/proxy/proxy_manager.go @@ -43,7 +43,8 @@ type Manager struct { encryptionKey []byte clientCfg *v1.ClientCommonConfig - ctx context.Context + ctx context.Context + udpPacketCodec string } func NewManager( @@ -52,6 +53,7 @@ func NewManager( encryptionKey []byte, msgTransporter transport.MessageTransporter, vnetController *vnet.Controller, + udpPacketCodec string, ) *Manager { return &Manager{ proxies: make(map[string]*Wrapper), @@ -61,6 +63,7 @@ func NewManager( encryptionKey: encryptionKey, clientCfg: clientCfg, ctx: ctx, + udpPacketCodec: udpPacketCodec, } } @@ -166,7 +169,7 @@ func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) { for _, cfg := range proxyCfgs { name := cfg.GetBaseConfig().Name if _, ok := pm.proxies[name]; !ok { - pxy := NewWrapper(pm.ctx, cfg, pm.clientCfg, pm.encryptionKey, pm.HandleEvent, pm.msgTransporter, pm.vnetController) + pxy := NewWrapper(pm.ctx, cfg, pm.clientCfg, pm.encryptionKey, pm.HandleEvent, pm.msgTransporter, pm.vnetController, pm.udpPacketCodec) if pm.inWorkConnCallback != nil { pxy.SetInWorkConnCallback(pm.inWorkConnCallback) } diff --git a/client/proxy/proxy_test.go b/client/proxy/proxy_test.go new file mode 100644 index 00000000..c51c6997 --- /dev/null +++ b/client/proxy/proxy_test.go @@ -0,0 +1,47 @@ +// Copyright 2026 The frp Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !frps + +package proxy + +import ( + "io" + "net" + "testing" + + "github.com/stretchr/testify/require" + + v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/fatedier/frp/pkg/msg" + "github.com/fatedier/frp/pkg/util/xlog" +) + +func TestHandleTCPWorkConnectionRejectsInvalidAddress(t *testing.T) { + workConn, peerConn := net.Pipe() + defer peerConn.Close() + + pxy := &BaseProxy{ + baseCfg: &v1.ProxyBaseConfig{}, + xl: xlog.New(), + } + pxy.HandleTCPWorkConnection(workConn, &msg.StartWorkConn{ + SrcAddr: "[", + SrcPort: 1, + }, nil) + + buffer := make([]byte, 1) + _, err := peerConn.Read(buffer) + require.ErrorIs(t, err, io.EOF) +} diff --git a/client/proxy/proxy_wrapper.go b/client/proxy/proxy_wrapper.go index 718c02e6..37a71277 100644 --- a/client/proxy/proxy_wrapper.go +++ b/client/proxy/proxy_wrapper.go @@ -99,6 +99,7 @@ func NewWrapper( eventHandler event.Handler, msgTransporter transport.MessageTransporter, vnetController *vnet.Controller, + udpPacketCodec string, ) *Wrapper { baseInfo := cfg.GetBaseConfig() xl := xlog.FromContextSafe(ctx).Spawn().AppendPrefix(baseInfo.Name) @@ -127,7 +128,7 @@ func NewWrapper( xl.Tracef("enable health check monitor") } - pw.pxy = NewProxy(pw.ctx, pw.Cfg, clientCfg, encryptionKey, pw.msgTransporter, pw.vnetController) + pw.pxy = NewProxy(pw.ctx, pw.Cfg, clientCfg, encryptionKey, pw.msgTransporter, pw.vnetController, udpPacketCodec) return pw } diff --git a/client/proxy/sudp.go b/client/proxy/sudp.go index 7db6d991..b5613856 100644 --- a/client/proxy/sudp.go +++ b/client/proxy/sudp.go @@ -87,7 +87,13 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) { } workConn := netpkg.WrapReadWriteCloserToConn(remote, conn) - payloadConn := msg.NewConn(workConn, msg.NewReadWriter(workConn, pxy.clientCfg.Transport.WireProtocol)) + payloadRW, err := msg.NewUDPPacketReadWriter(workConn, pxy.clientCfg.Transport.WireProtocol, pxy.udpPacketCodec) + if err != nil { + xl.Errorf("create SUDP packet read writer: %v", err) + _ = workConn.Close() + return + } + payloadConn := msg.NewConn(workConn, payloadRW) readCh := make(chan *msg.UDPPacket, 1024) sendCh := make(chan msg.Message, 1024) isClose := false diff --git a/client/proxy/udp.go b/client/proxy/udp.go index 110c01ac..d01d2c8e 100644 --- a/client/proxy/udp.go +++ b/client/proxy/udp.go @@ -97,10 +97,17 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) { return } - pxy.mu.Lock() - pxy.workConn = netpkg.WrapReadWriteCloserToConn(remote, conn) + workConn := netpkg.WrapReadWriteCloserToConn(remote, conn) // Plain UDP payload follows the configured wire protocol for message framing. - payloadRW := msg.NewReadWriter(pxy.workConn, pxy.clientCfg.Transport.WireProtocol) + payloadRW, err := msg.NewUDPPacketReadWriter(workConn, pxy.clientCfg.Transport.WireProtocol, pxy.udpPacketCodec) + if err != nil { + xl.Errorf("create UDP packet read writer: %v", err) + workConn.Close() + return + } + + pxy.mu.Lock() + pxy.workConn = workConn pxy.readCh = make(chan *msg.UDPPacket, 1024) pxy.sendCh = make(chan msg.Message, 1024) pxy.closed = false diff --git a/client/service.go b/client/service.go index 40428719..7964050f 100644 --- a/client/service.go +++ b/client/service.go @@ -33,6 +33,7 @@ import ( "github.com/fatedier/frp/pkg/config" "github.com/fatedier/frp/pkg/config/source" v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/fatedier/frp/pkg/config/v1/validation" "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/policy/security" httppkg "github.com/fatedier/frp/pkg/util/http" @@ -510,6 +511,13 @@ func (svr *Service) reloadConfigFromSourcesLocked() error { proxies, visitors = config.FilterClientConfigurers(reloadCommon, proxies, visitors) proxies = config.CompleteProxyConfigurers(proxies) visitors = config.CompleteVisitorConfigurers(visitors) + requirements := validation.GetClientConfigRequirements(reloadCommon, proxies, visitors) + if svr.vnetController == nil && requirements.VirtualNet { + return errors.New( + "VirtualNet-dependent configuration requires a VirtualNet runtime enabled at startup; " + + "restart frpc after configuring featureGates.VirtualNet and virtualNet.address", + ) + } // Atomically replace the entire configuration if err := svr.UpdateAllConfigurer(proxies, visitors); err != nil { diff --git a/client/service_shutdown_test.go b/client/service_shutdown_test.go index 5c710bec..73c7bd3d 100644 --- a/client/service_shutdown_test.go +++ b/client/service_shutdown_test.go @@ -34,8 +34,8 @@ func newGracefulCloseTestService() *Service { }, doneCh: make(chan struct{}), } - ctl.pm = proxy.NewManager(ctx, common, nil, nil, nil) - ctl.vm = visitor.NewManager(ctx, "graceful-close-race", common, nil, nil, nil) + ctl.pm = proxy.NewManager(ctx, common, nil, nil, nil, "") + ctl.vm = visitor.NewManager(ctx, "graceful-close-race", common, nil, nil, nil, "") return &Service{ctl: ctl, cancel: context.CancelCauseFunc(func(error) {})} } diff --git a/client/visitor/sudp.go b/client/visitor/sudp.go index 91d57da6..f1939bda 100644 --- a/client/visitor/sudp.go +++ b/client/visitor/sudp.go @@ -113,7 +113,13 @@ func (sv *SUDPVisitor) dispatcher() { func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) { xl := xlog.FromContextSafe(sv.ctx) xl.Debugf("starting sudp proxy worker") - payloadConn := msg.NewConn(workConn, msg.NewReadWriter(workConn, sv.clientCfg.Transport.WireProtocol)) + payloadRW, err := msg.NewUDPPacketReadWriter(workConn, sv.clientCfg.Transport.WireProtocol, udpPacketCodecFromHelper(sv.helper)) + if err != nil { + xl.Errorf("create SUDP packet read writer: %v", err) + _ = workConn.Close() + return + } + payloadConn := msg.NewConn(workConn, payloadRW) wg := &sync.WaitGroup{} wg.Add(2) diff --git a/client/visitor/visitor.go b/client/visitor/visitor.go index dff0bb94..eccad013 100644 --- a/client/visitor/visitor.go +++ b/client/visitor/visitor.go @@ -50,6 +50,17 @@ type Helper interface { RunID() string } +type udpPacketCodecProvider interface { + UDPPacketCodec() string +} + +func udpPacketCodecFromHelper(helper Helper) string { + if provider, ok := helper.(udpPacketCodecProvider); ok { + return provider.UDPPacketCodec() + } + return "" +} + // Visitor is used for forward traffics from local port tot remote service. type Visitor interface { Run() error diff --git a/client/visitor/visitor_manager.go b/client/visitor/visitor_manager.go index 1ca194bd..3e614f7a 100644 --- a/client/visitor/visitor_manager.go +++ b/client/visitor/visitor_manager.go @@ -53,7 +53,12 @@ func NewManager( connectServer func() (*msg.Conn, error), msgTransporter transport.MessageTransporter, vnetController *vnet.Controller, + udpPacketCodecs ...string, ) *Manager { + udpPacketCodec := "" + if len(udpPacketCodecs) > 0 { + udpPacketCodec = udpPacketCodecs[0] + } m := &Manager{ clientCfg: clientCfg, cfgs: make(map[string]v1.VisitorConfigurer), @@ -68,6 +73,7 @@ func NewManager( vnetController: vnetController, transferConnFn: m.TransferConn, runID: runID, + udpPacketCodec: udpPacketCodec, } return m } @@ -205,6 +211,7 @@ type visitorHelperImpl struct { vnetController *vnet.Controller transferConnFn func(name string, conn net.Conn) error runID string + udpPacketCodec string } func (v *visitorHelperImpl) ConnectServer() (*msg.Conn, error) { @@ -226,3 +233,7 @@ func (v *visitorHelperImpl) VNetController() *vnet.Controller { func (v *visitorHelperImpl) RunID() string { return v.runID } + +func (v *visitorHelperImpl) UDPPacketCodec() string { + return v.udpPacketCodec +} diff --git a/cmd/frpc/sub/root.go b/cmd/frpc/sub/root.go index 5d83cabf..8a0f1842 100644 --- a/cmd/frpc/sub/root.go +++ b/cmd/frpc/sub/root.go @@ -33,7 +33,6 @@ import ( "github.com/fatedier/frp/pkg/config/source" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/config/v1/validation" - "github.com/fatedier/frp/pkg/policy/featuregate" "github.com/fatedier/frp/pkg/policy/security" "github.com/fatedier/frp/pkg/util/log" "github.com/fatedier/frp/pkg/util/version" @@ -131,12 +130,6 @@ func runClient(cfgFilePath string, unsafeFeatures *security.UnsafeFeatures) erro "please use yaml/json/toml format instead!\n") } - if len(result.Common.FeatureGates) > 0 { - if err := featuregate.SetFromMap(result.Common.FeatureGates); err != nil { - return err - } - } - return runClientWithAggregator(result, unsafeFeatures, cfgFilePath) } diff --git a/cmd/frpc/sub/verify.go b/cmd/frpc/sub/verify.go index 830f7bf1..d226c473 100644 --- a/cmd/frpc/sub/verify.go +++ b/cmd/frpc/sub/verify.go @@ -29,6 +29,18 @@ func init() { rootCmd.AddCommand(verifyCmd) } +func verifyClientConfig( + configFile string, + strict bool, + unsafeFeatures *security.UnsafeFeatures, +) (validation.Warning, error) { + cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(configFile, strict) + if err != nil { + return nil, err + } + return validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, unsafeFeatures) +} + var verifyCmd = &cobra.Command{ Use: "verify", Short: "Verify that the configures is valid", @@ -38,13 +50,8 @@ var verifyCmd = &cobra.Command{ return nil } - cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(cfgFile, strictConfigMode) - if err != nil { - fmt.Println(err) - os.Exit(1) - } unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe) - warning, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, unsafeFeatures) + warning, err := verifyClientConfig(cfgFile, strictConfigMode, unsafeFeatures) if warning != nil { fmt.Printf("WARNING: %v\n", warning) } diff --git a/cmd/frpc/sub/verify_test.go b/cmd/frpc/sub/verify_test.go new file mode 100644 index 00000000..b93db23b --- /dev/null +++ b/cmd/frpc/sub/verify_test.go @@ -0,0 +1,67 @@ +// Copyright 2026 The frp Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sub + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/fatedier/frp/pkg/policy/security" +) + +func TestVerifyClientConfigFeatureGates(t *testing.T) { + tests := []struct { + name string + content string + wantErr string + }{ + { + name: "VirtualNet enabled", + content: `featureGates = { VirtualNet = true } +virtualNet.address = "100.86.0.4/24" +`, + }, + { + name: "VirtualNet disabled", + content: `featureGates = { VirtualNet = false } +virtualNet.address = "100.86.0.4/24" +`, + wantErr: "VirtualNet feature is not enabled", + }, + { + name: "unknown feature gate", + content: `featureGates = { UnknownFeature = true }`, + wantErr: "unrecognized feature gate: UnknownFeature", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + configFile := filepath.Join(t.TempDir(), "frpc.toml") + require.NoError(t, os.WriteFile(configFile, []byte(tc.content), 0o600)) + + warning, err := verifyClientConfig(configFile, true, security.NewUnsafeFeatures(nil)) + require.NoError(t, warning) + if tc.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tc.wantErr) + }) + } +} diff --git a/go.mod b/go.mod index c1ca1917..9e02c333 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.0 require ( github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 github.com/coreos/go-oidc/v3 v3.18.0 - github.com/fatedier/golib v0.8.1 + github.com/fatedier/golib v0.8.2 github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 github.com/gorilla/websocket v1.5.0 @@ -13,7 +13,7 @@ require ( github.com/onsi/ginkgo/v2 v2.23.4 github.com/onsi/gomega v1.36.3 github.com/pelletier/go-toml/v2 v2.2.0 - github.com/pires/go-proxyproto v0.7.0 + github.com/pires/go-proxyproto v0.15.0 github.com/prometheus/client_golang v1.19.1 github.com/quic-go/quic-go v0.60.0 github.com/rodaine/table v1.2.0 diff --git a/go.sum b/go.sum index 81758d50..30641d78 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/fatedier/golib v0.8.1 h1:pHcIu0zAcZ6VTkO1dW/meelCGN5nem52DKCBY7cUvyA= -github.com/fatedier/golib v0.8.1/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw= +github.com/fatedier/golib v0.8.2 h1:02n2Dg7KJ7rR7p7n4/6hBUjaLQf2J7EiHYZQsgGTvww= +github.com/fatedier/golib v0.8.2/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw= github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE= github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= @@ -78,8 +78,8 @@ github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU= github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo= github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= -github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs= -github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4= +github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI= +github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/pkg/config/v1/server.go b/pkg/config/v1/server.go index a92aac97..67c1d84b 100644 --- a/pkg/config/v1/server.go +++ b/pkg/config/v1/server.go @@ -167,7 +167,7 @@ type ServerTransportConfig struct { // If negative, keep-alive probes are disabled. TCPKeepAlive int64 `json:"tcpKeepalive,omitempty"` // MaxPoolCount specifies the maximum pool size for each proxy. By default, - // this value is 5. + // this value is 5. Negative values are invalid. MaxPoolCount int64 `json:"maxPoolCount,omitempty"` // HeartBeatTimeout specifies the maximum time to wait for a heartbeat // before terminating the connection. It is not recommended to change this diff --git a/pkg/config/v1/validation/client.go b/pkg/config/v1/validation/client.go index 77004317..28409c4b 100644 --- a/pkg/config/v1/validation/client.go +++ b/pkg/config/v1/validation/client.go @@ -51,14 +51,51 @@ func (v *ConfigValidator) ValidateClientCommonConfig(c *v1.ClientCommonConfig) ( } func validateFeatureGates(c *v1.ClientCommonConfig) (Warning, error) { + gates := featuregate.NewFeatureGate() + if err := gates.SetFromMap(c.FeatureGates); err != nil { + return nil, err + } + if c.VirtualNet.Address != "" { - if !featuregate.Enabled(featuregate.VirtualNet) { + if !gates.Enabled(featuregate.VirtualNet) { return nil, fmt.Errorf("VirtualNet feature is not enabled; enable it by setting the appropriate feature gate flag") } } return nil, nil } +// ClientConfigRequirements describes runtime capabilities needed by a client configuration. +type ClientConfigRequirements struct { + VirtualNet bool +} + +// GetClientConfigRequirements returns the runtime capabilities needed by a client configuration. +func GetClientConfigRequirements( + common *v1.ClientCommonConfig, + proxyCfgs []v1.ProxyConfigurer, + visitorCfgs []v1.VisitorConfigurer, +) ClientConfigRequirements { + requirements := ClientConfigRequirements{} + if common != nil && common.VirtualNet.Address != "" { + requirements.VirtualNet = true + } + for _, cfg := range proxyCfgs { + if cfg.GetBaseConfig().Plugin.Type == v1.PluginVirtualNet { + requirements.VirtualNet = true + break + } + } + if !requirements.VirtualNet { + for _, cfg := range visitorCfgs { + if cfg.GetBaseConfig().Plugin.Type == v1.VisitorPluginVirtualNet { + requirements.VirtualNet = true + break + } + } + } + return requirements +} + func (v *ConfigValidator) validateAuthConfig(c *v1.AuthClientConfig) (Warning, error) { var errs error if !slices.Contains(SupportedAuthMethods, c.Method) { diff --git a/pkg/config/v1/validation/client_test.go b/pkg/config/v1/validation/client_test.go new file mode 100644 index 00000000..1370e890 --- /dev/null +++ b/pkg/config/v1/validation/client_test.go @@ -0,0 +1,140 @@ +// Copyright 2026 The frp Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package validation + +import ( + "testing" + + "github.com/stretchr/testify/require" + + v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/fatedier/frp/pkg/policy/featuregate" + "github.com/fatedier/frp/pkg/policy/security" +) + +func validateClientFeatureGates(t *testing.T, gates map[string]bool, virtualNetAddress string) error { + t.Helper() + + cfg := &v1.ClientCommonConfig{ + FeatureGates: gates, + VirtualNet: v1.VirtualNetConfig{ + Address: virtualNetAddress, + }, + } + require.NoError(t, cfg.Complete()) + + _, err := NewConfigValidator(security.NewUnsafeFeatures(nil)).ValidateClientCommonConfig(cfg) + return err +} + +func TestValidateClientFeatureGates(t *testing.T) { + tests := []struct { + name string + featureGates map[string]bool + virtualNetAddress string + wantErr string + }{ + { + name: "VirtualNet enabled", + featureGates: map[string]bool{"VirtualNet": true}, + virtualNetAddress: "100.86.0.4/24", + }, + { + name: "VirtualNet explicitly disabled", + featureGates: map[string]bool{"VirtualNet": false}, + virtualNetAddress: "100.86.0.4/24", + wantErr: "VirtualNet feature is not enabled", + }, + { + name: "VirtualNet disabled by default", + virtualNetAddress: "100.86.0.4/24", + wantErr: "VirtualNet feature is not enabled", + }, + { + name: "unknown feature gate", + featureGates: map[string]bool{"UnknownFeature": true}, + wantErr: "unrecognized feature gate: UnknownFeature", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := validateClientFeatureGates(t, tc.featureGates, tc.virtualNetAddress) + if tc.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tc.wantErr) + }) + } +} + +func TestGetClientConfigRequirements(t *testing.T) { + virtualNetProxy := &v1.STCPProxyConfig{ + ProxyBaseConfig: v1.ProxyBaseConfig{ + ProxyBackend: v1.ProxyBackend{ + Plugin: v1.TypedClientPluginOptions{Type: v1.PluginVirtualNet}, + }, + }, + } + virtualNetVisitor := &v1.STCPVisitorConfig{ + VisitorBaseConfig: v1.VisitorBaseConfig{ + Plugin: v1.TypedVisitorPluginOptions{Type: v1.VisitorPluginVirtualNet}, + }, + } + + tests := []struct { + name string + common *v1.ClientCommonConfig + proxies []v1.ProxyConfigurer + visitors []v1.VisitorConfigurer + wantVNet bool + }{ + {name: "no requirements"}, + { + name: "common VirtualNet address", + common: &v1.ClientCommonConfig{VirtualNet: v1.VirtualNetConfig{Address: "100.86.0.4/24"}}, + wantVNet: true, + }, + {name: "VirtualNet proxy", proxies: []v1.ProxyConfigurer{virtualNetProxy}, wantVNet: true}, + {name: "VirtualNet visitor", visitors: []v1.VisitorConfigurer{virtualNetVisitor}, wantVNet: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := GetClientConfigRequirements(tc.common, tc.proxies, tc.visitors) + require.Equal(t, tc.wantVNet, got.VirtualNet) + }) + } +} + +func TestValidateClientFeatureGatesAreConfigScoped(t *testing.T) { + defaultGatesBefore := featuregate.DefaultFeatureGates.String() + + require.NoError(t, validateClientFeatureGates( + t, + map[string]bool{"VirtualNet": true}, + "100.86.0.4/24", + )) + require.Equal(t, defaultGatesBefore, featuregate.DefaultFeatureGates.String()) + + err := validateClientFeatureGates( + t, + map[string]bool{"VirtualNet": false}, + "100.86.0.4/24", + ) + require.ErrorContains(t, err, "VirtualNet feature is not enabled") + require.Equal(t, defaultGatesBefore, featuregate.DefaultFeatureGates.String()) +} diff --git a/pkg/config/v1/validation/name.go b/pkg/config/v1/validation/name.go new file mode 100644 index 00000000..ce03de88 --- /dev/null +++ b/pkg/config/v1/validation/name.go @@ -0,0 +1,48 @@ +// Copyright 2026 The frp Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package validation + +import ( + "fmt" + "unicode" + "unicode/utf8" +) + +const ( + // MaxRunIDLength is the maximum number of bytes accepted for a control run ID. + MaxRunIDLength = 64 +) + +func validateIdentifier(value, kind string, maxLength int) error { + if value == "" { + return fmt.Errorf("%s cannot be empty", kind) + } + if len(value) > maxLength { + return fmt.Errorf("%s is too long: length %d exceeds maximum %d", kind, len(value), maxLength) + } + if !utf8.ValidString(value) { + return fmt.Errorf("%s must be valid UTF-8", kind) + } + for _, r := range value { + if !unicode.IsPrint(r) { + return fmt.Errorf("%s contains non-printable character", kind) + } + } + return nil +} + +func ValidateRunID(runID string) error { + return validateIdentifier(runID, "run id", MaxRunIDLength) +} diff --git a/pkg/config/v1/validation/name_test.go b/pkg/config/v1/validation/name_test.go new file mode 100644 index 00000000..1095e78f --- /dev/null +++ b/pkg/config/v1/validation/name_test.go @@ -0,0 +1,48 @@ +// Copyright 2026 The frp Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package validation + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidateIdentifiers(t *testing.T) { + tests := []struct { + name string + validate func(string) error + value string + wantError string + }{ + {name: "run id accepts printable values", validate: ValidateRunID, value: "run-%1000s-中文"}, + {name: "run id rejects empty", validate: ValidateRunID, wantError: "cannot be empty"}, + {name: "run id rejects control character", validate: ValidateRunID, value: "run\nforged", wantError: "non-printable"}, + {name: "run id rejects invalid utf8", validate: ValidateRunID, value: string([]byte{0xff}), wantError: "valid UTF-8"}, + {name: "run id rejects excessive length", validate: ValidateRunID, value: strings.Repeat("a", MaxRunIDLength+1), wantError: "too long"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.validate(tt.value) + if tt.wantError == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tt.wantError) + }) + } +} diff --git a/pkg/config/v1/validation/proxy.go b/pkg/config/v1/validation/proxy.go index 744620f0..5d430e6b 100644 --- a/pkg/config/v1/validation/proxy.go +++ b/pkg/config/v1/validation/proxy.go @@ -79,9 +79,11 @@ func validateDomainConfigForClient(c *v1.DomainConfig) error { } func validateDomainConfigForServer(c *v1.DomainConfig, s *v1.ServerConfig) error { + subDomainHost := strings.ToLower(s.SubDomainHost) for _, domain := range c.CustomDomains { - if s.SubDomainHost != "" && len(strings.Split(s.SubDomainHost, ".")) < len(strings.Split(domain, ".")) { - if strings.HasSuffix(domain, "."+s.SubDomainHost) { + canonicalDomain := strings.ToLower(domain) + if subDomainHost != "" && len(strings.Split(subDomainHost, ".")) < len(strings.Split(canonicalDomain, ".")) { + if strings.HasSuffix(canonicalDomain, "."+subDomainHost) { return fmt.Errorf("custom domain [%s] should not belong to subdomain host [%s]", domain, s.SubDomainHost) } } diff --git a/pkg/config/v1/validation/proxy_test.go b/pkg/config/v1/validation/proxy_test.go new file mode 100644 index 00000000..b9411dc8 --- /dev/null +++ b/pkg/config/v1/validation/proxy_test.go @@ -0,0 +1,76 @@ +// Copyright 2026 The frp Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package validation + +import ( + "testing" + + "github.com/stretchr/testify/require" + + v1 "github.com/fatedier/frp/pkg/config/v1" +) + +func TestValidateDomainConfigForServerRejectsSubdomainHostCaseInsensitively(t *testing.T) { + tests := []struct { + name string + subDomainHost string + customDomain string + wantErr bool + }{ + { + name: "lowercase subdomain", + subDomainHost: "frp.example.com", + customDomain: "victim.frp.example.com", + wantErr: true, + }, + { + name: "mixed case custom domain", + subDomainHost: "frp.example.com", + customDomain: "victim.FRP.example.com", + wantErr: true, + }, + { + name: "mixed case wildcard domain", + subDomainHost: "frp.example.com", + customDomain: "*.FRP.example.com", + wantErr: true, + }, + { + name: "mixed case subdomain host", + subDomainHost: "FRP.Example.Com", + customDomain: "victim.frp.example.com", + wantErr: true, + }, + { + name: "external domain", + subDomainHost: "frp.example.com", + customDomain: "victim.example.net", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateDomainConfigForServer( + &v1.DomainConfig{CustomDomains: []string{tt.customDomain}}, + &v1.ServerConfig{SubDomainHost: tt.subDomainHost}, + ) + if tt.wantErr { + require.ErrorContains(t, err, "should not belong to subdomain host") + return + } + require.NoError(t, err) + }) + } +} diff --git a/pkg/config/v1/validation/server.go b/pkg/config/v1/validation/server.go index 8be740aa..54bd8348 100644 --- a/pkg/config/v1/validation/server.go +++ b/pkg/config/v1/validation/server.go @@ -51,6 +51,9 @@ func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, err errs = AppendError(errs, ValidatePort(c.VhostHTTPPort, "vhostHTTPPort")) errs = AppendError(errs, ValidatePort(c.VhostHTTPSPort, "vhostHTTPSPort")) errs = AppendError(errs, ValidatePort(c.TCPMuxHTTPConnectPort, "tcpMuxHTTPConnectPort")) + if c.Transport.MaxPoolCount < 0 { + errs = AppendError(errs, fmt.Errorf("invalid transport.maxPoolCount, must be non-negative")) + } for _, p := range c.HTTPPlugins { if !lo.Every(SupportedHTTPPluginOps, p.Ops) { diff --git a/pkg/config/v1/validation/server_test.go b/pkg/config/v1/validation/server_test.go new file mode 100644 index 00000000..7c0c57ad --- /dev/null +++ b/pkg/config/v1/validation/server_test.go @@ -0,0 +1,51 @@ +// Copyright 2026 The frp Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package validation + +import ( + "math" + "testing" + + "github.com/stretchr/testify/require" + + v1 "github.com/fatedier/frp/pkg/config/v1" +) + +func TestValidateServerConfigMaxPoolCount(t *testing.T) { + for _, tc := range []struct { + name string + maxPoolCount int64 + wantErr bool + }{ + {name: "negative", maxPoolCount: -1, wantErr: true}, + {name: "zero", maxPoolCount: 0}, + {name: "positive", maxPoolCount: 5}, + {name: "maximum int64", maxPoolCount: math.MaxInt64}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := validServerConfigWithAuth(v1.AuthServerConfig{Method: v1.AuthMethodToken}) + cfg.Transport.MaxPoolCount = tc.maxPoolCount + require.NoError(t, cfg.Complete()) + + _, err := NewConfigValidator(nil).ValidateServerConfig(cfg) + if tc.wantErr { + require.ErrorContains(t, err, "invalid transport.maxPoolCount") + require.ErrorContains(t, err, "must be non-negative") + return + } + require.NoError(t, err) + }) + } +} diff --git a/pkg/msg/udp_benchmark_test.go b/pkg/msg/udp_benchmark_test.go new file mode 100644 index 00000000..575938ec --- /dev/null +++ b/pkg/msg/udp_benchmark_test.go @@ -0,0 +1,199 @@ +// Copyright 2026 The frp Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package msg + +import ( + "bytes" + "fmt" + "net" + "runtime" + "testing" + + "github.com/fatedier/frp/pkg/proto/wire" +) + +type udpBenchmarkCase struct { + name string + packet *UDPPacket +} + +var ( + udpBenchmarkBytesSink []byte + udpBenchmarkMessageSink Message +) + +func udpBenchmarkCases(payloadSize int) []udpBenchmarkCase { + content := bytes.Repeat([]byte{0x5a}, payloadSize) + return []udpBenchmarkCase{ + { + name: "ipv4-remote", + packet: &UDPPacket{ + Content: content, + RemoteAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 12345}, + }, + }, + { + name: "ipv4-local-remote", + packet: &UDPPacket{ + Content: content, + LocalAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.2"), Port: 23456}, + RemoteAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 12345}, + }, + }, + { + name: "ipv6-remote", + packet: &UDPPacket{ + Content: content, + RemoteAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 12345}, + }, + }, + { + name: "ipv6-local-remote", + packet: &UDPPacket{ + Content: content, + LocalAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::2"), Port: 23456, Zone: "bench0"}, + RemoteAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 12345, Zone: "bench1"}, + }, + }, + } +} + +func TestUDPPacketV2FrameSizes(t *testing.T) { + t.Logf("environment go=%s goos=%s goarch=%s gomaxprocs=%d", runtime.Version(), runtime.GOOS, runtime.GOARCH, runtime.GOMAXPROCS(0)) + for _, payloadSize := range []int{64, 512, 1200, 1472} { + for _, tc := range udpBenchmarkCases(payloadSize) { + jsonFrame := udpBenchmarkWireBytes(t, tc.packet, "") + binaryFrame := udpBenchmarkWireBytes(t, tc.packet, wire.UDPPacketCodecBinary) + saving := 100 * float64(len(jsonFrame)-len(binaryFrame)) / float64(len(jsonFrame)) + t.Logf("frame payload=%d case=%s json_bytes=%d binary_bytes=%d binary_saving_pct=%.2f", payloadSize, tc.name, len(jsonFrame), len(binaryFrame), saving) + } + } +} + +func udpBenchmarkWireBytes(t testing.TB, packet *UDPPacket, codec string) []byte { + t.Helper() + var buf bytes.Buffer + rw, err := NewUDPPacketReadWriter(&buf, wire.ProtocolV2, codec) + if err != nil { + t.Fatalf("create UDP read writer: %v", err) + } + if err := rw.WriteMsg(packet); err != nil { + t.Fatalf("write UDP packet: %v", err) + } + return append([]byte(nil), buf.Bytes()...) +} + +type udpBenchmarkReadWriter struct { + reader bytes.Reader +} + +func (rw *udpBenchmarkReadWriter) Read(p []byte) (int, error) { + return rw.reader.Read(p) +} + +func (rw *udpBenchmarkReadWriter) Write(p []byte) (int, error) { + return len(p), nil +} + +func (rw *udpBenchmarkReadWriter) Reset(p []byte) { + rw.reader.Reset(p) +} + +func udpBenchmarkValidatePacket(b testing.TB, got, want *UDPPacket) { + b.Helper() + if !bytes.Equal(got.Content, want.Content) || !udpBenchmarkUDPAddrEqual(got.LocalAddr, want.LocalAddr) || + !udpBenchmarkUDPAddrEqual(got.RemoteAddr, want.RemoteAddr) { + b.Fatalf("decoded packet mismatch: got %+v, want %+v", got, want) + } +} + +func udpBenchmarkUDPAddrEqual(got, want *net.UDPAddr) bool { + if got == nil || want == nil { + return got == want + } + return got.IP.Equal(want.IP) && got.Port == want.Port && got.Zone == want.Zone +} + +func BenchmarkUDPPacketV2CodecWrite(b *testing.B) { + for _, payloadSize := range []int{64, 512, 1200, 1472} { + for _, tc := range udpBenchmarkCases(payloadSize) { + for _, codec := range []struct { + name string + value string + }{ + {name: "json", value: ""}, + {name: "binary", value: wire.UDPPacketCodecBinary}, + } { + b.Run(fmt.Sprintf("payload-%d/%s/%s", payloadSize, tc.name, codec.name), func(b *testing.B) { + var buf bytes.Buffer + rw, err := NewUDPPacketReadWriter(&buf, wire.ProtocolV2, codec.value) + if err != nil { + b.Fatal(err) + } + expected := udpBenchmarkWireBytes(b, tc.packet, codec.value) + b.SetBytes(int64(len(expected))) + for b.Loop() { + buf.Reset() + if err := rw.WriteMsg(tc.packet); err != nil { + b.Fatal(err) + } + } + if !bytes.Equal(buf.Bytes(), expected) { + b.Fatalf("encoded packet mismatch: got %d bytes, want %d", buf.Len(), len(expected)) + } + udpBenchmarkBytesSink = buf.Bytes() + }) + } + } + } +} + +func BenchmarkUDPPacketV2CodecRead(b *testing.B) { + for _, payloadSize := range []int{64, 512, 1200, 1472} { + for _, tc := range udpBenchmarkCases(payloadSize) { + for _, codec := range []struct { + name string + value string + }{ + {name: "json", value: ""}, + {name: "binary", value: wire.UDPPacketCodecBinary}, + } { + b.Run(fmt.Sprintf("payload-%d/%s/%s", payloadSize, tc.name, codec.name), func(b *testing.B) { + encoded := udpBenchmarkWireBytes(b, tc.packet, codec.value) + stream := &udpBenchmarkReadWriter{} + rw, err := NewUDPPacketReadWriter(stream, wire.ProtocolV2, codec.value) + if err != nil { + b.Fatal(err) + } + var decoded Message + b.SetBytes(int64(len(encoded))) + for b.Loop() { + stream.Reset(encoded) + decoded, err = rw.ReadMsg() + if err != nil { + b.Fatal(err) + } + } + packet, ok := decoded.(*UDPPacket) + if !ok { + b.Fatalf("decoded message type %T, want *UDPPacket", decoded) + } + udpBenchmarkValidatePacket(b, packet, tc.packet) + udpBenchmarkMessageSink = decoded + }) + } + } + } +} diff --git a/pkg/msg/udp_binary.go b/pkg/msg/udp_binary.go new file mode 100644 index 00000000..7493c70f --- /dev/null +++ b/pkg/msg/udp_binary.go @@ -0,0 +1,338 @@ +// Copyright 2026 The frp Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package msg + +import ( + "encoding/binary" + "fmt" + "io" + "net" + "unicode/utf8" + + "github.com/fatedier/frp/pkg/proto/wire" +) + +const MaxUDPPayloadSize = 65507 + +const ( + udpPacketFlagLocalAddr byte = 1 << 0 + udpPacketFlagRemoteAddr byte = 1 << 1 + udpPacketValidFlags = udpPacketFlagLocalAddr | udpPacketFlagRemoteAddr +) + +type binaryUDPAddr struct { + family byte + ip []byte + port uint16 + zone string +} + +// EncodeUDPPacketBinary encodes the body of a V2 binary UDP packet message. +// RemoteAddr is required by the UDP forwarding path. +func EncodeUDPPacketBinary(packet *UDPPacket) ([]byte, error) { + if packet == nil { + return nil, fmt.Errorf("nil UDP packet") + } + if packet.RemoteAddr == nil { + return nil, fmt.Errorf("UDP packet missing remote address") + } + if len(packet.Content) > MaxUDPPayloadSize { + return nil, fmt.Errorf("UDP payload length %d exceeds limit %d", len(packet.Content), MaxUDPPayloadSize) + } + + var flags byte + var localAddr, remoteAddr binaryUDPAddr + bodyLen := 1 + 2 + len(packet.Content) + if packet.LocalAddr != nil { + flags |= udpPacketFlagLocalAddr + var err error + localAddr, err = validateBinaryUDPAddr(packet.LocalAddr) + if err != nil { + return nil, fmt.Errorf("local address: %w", err) + } + bodyLen += binaryUDPAddrLen(localAddr) + } + flags |= udpPacketFlagRemoteAddr + var err error + remoteAddr, err = validateBinaryUDPAddr(packet.RemoteAddr) + if err != nil { + return nil, fmt.Errorf("remote address: %w", err) + } + bodyLen += binaryUDPAddrLen(remoteAddr) + if 2+bodyLen > wire.DefaultMaxFramePayloadSize { + return nil, fmt.Errorf("v2 frame payload length %d exceeds limit %d", 2+bodyLen, wire.DefaultMaxFramePayloadSize) + } + + body := make([]byte, bodyLen) + body[0] = flags + offset := 1 + if flags&udpPacketFlagLocalAddr != 0 { + offset = putBinaryUDPAddr(body, offset, localAddr) + } + offset = putBinaryUDPAddr(body, offset, remoteAddr) + binary.BigEndian.PutUint16(body[offset:offset+2], uint16(len(packet.Content))) + offset += 2 + copy(body[offset:], packet.Content) + return body, nil +} + +// DecodeUDPPacketBinary decodes a V2 binary UDP packet body and returns data +// that does not alias the input frame buffer. +func DecodeUDPPacketBinary(body []byte) (*UDPPacket, error) { + if len(body) < 3 { + return nil, fmt.Errorf("UDP packet body too short: %d", len(body)) + } + if 2+len(body) > wire.DefaultMaxFramePayloadSize { + return nil, fmt.Errorf("v2 frame payload length %d exceeds limit %d", 2+len(body), wire.DefaultMaxFramePayloadSize) + } + + flags := body[0] + if flags&^udpPacketValidFlags != 0 { + return nil, fmt.Errorf("reserved UDP packet flags set: 0x%02x", flags) + } + if flags&udpPacketFlagRemoteAddr == 0 { + return nil, fmt.Errorf("UDP packet missing remote address") + } + + packet := &UDPPacket{} + offset := 1 + var err error + if flags&udpPacketFlagLocalAddr != 0 { + packet.LocalAddr, offset, err = readBinaryUDPAddr(body, offset) + if err != nil { + return nil, fmt.Errorf("local address: %w", err) + } + } + if flags&udpPacketFlagRemoteAddr != 0 { + packet.RemoteAddr, offset, err = readBinaryUDPAddr(body, offset) + if err != nil { + return nil, fmt.Errorf("remote address: %w", err) + } + } + if len(body)-offset < 2 { + return nil, fmt.Errorf("truncated UDP payload length") + } + payloadLen := int(binary.BigEndian.Uint16(body[offset : offset+2])) + offset += 2 + if payloadLen > MaxUDPPayloadSize { + return nil, fmt.Errorf("UDP payload length %d exceeds limit %d", payloadLen, MaxUDPPayloadSize) + } + remaining := len(body) - offset + if remaining < payloadLen { + return nil, fmt.Errorf("truncated UDP payload: have %d want %d", remaining, payloadLen) + } + if remaining > payloadLen { + return nil, fmt.Errorf("trailing UDP packet bytes: %d", remaining-payloadLen) + } + packet.Content = append([]byte(nil), body[offset:offset+payloadLen]...) + return packet, nil +} + +func validateBinaryUDPAddr(addr *net.UDPAddr) (binaryUDPAddr, error) { + if addr.Port < 0 || addr.Port > 65535 { + return binaryUDPAddr{}, fmt.Errorf("port out of range: %d", addr.Port) + } + if ip := addr.IP.To4(); ip != nil { + if addr.Zone != "" { + return binaryUDPAddr{}, fmt.Errorf("IPv4 zone is forbidden") + } + return binaryUDPAddr{family: 4, ip: ip, port: uint16(addr.Port)}, nil + } + ip := addr.IP.To16() + if ip == nil { + return binaryUDPAddr{}, fmt.Errorf("invalid IP") + } + if len(addr.Zone) > 255 { + return binaryUDPAddr{}, fmt.Errorf("zone exceeds 255 bytes") + } + if !utf8.ValidString(addr.Zone) { + return binaryUDPAddr{}, fmt.Errorf("zone is not valid UTF-8") + } + return binaryUDPAddr{family: 6, ip: ip, port: uint16(addr.Port), zone: addr.Zone}, nil +} + +func binaryUDPAddrLen(addr binaryUDPAddr) int { + return 1 + len(addr.ip) + 2 + 1 + len(addr.zone) +} + +func putBinaryUDPAddr(body []byte, offset int, addr binaryUDPAddr) int { + body[offset] = addr.family + offset++ + copy(body[offset:], addr.ip) + offset += len(addr.ip) + binary.BigEndian.PutUint16(body[offset:offset+2], addr.port) + offset += 2 + body[offset] = byte(len(addr.zone)) + offset++ + copy(body[offset:], addr.zone) + return offset + len(addr.zone) +} + +func readBinaryUDPAddr(body []byte, offset int) (*net.UDPAddr, int, error) { + if offset >= len(body) { + return nil, offset, fmt.Errorf("truncated address family") + } + family := body[offset] + offset++ + var ipLen int + switch family { + case 4: + ipLen = net.IPv4len + case 6: + ipLen = net.IPv6len + default: + return nil, offset, fmt.Errorf("unknown address family %d", family) + } + if len(body)-offset < ipLen+3 { + return nil, offset, fmt.Errorf("truncated address") + } + ip := append(net.IP(nil), body[offset:offset+ipLen]...) + offset += ipLen + port := binary.BigEndian.Uint16(body[offset : offset+2]) + offset += 2 + zoneLen := int(body[offset]) + offset++ + if len(body)-offset < zoneLen { + return nil, offset, fmt.Errorf("truncated zone") + } + zoneBytes := body[offset : offset+zoneLen] + if family == 4 && zoneLen != 0 { + return nil, offset, fmt.Errorf("IPv4 zone is forbidden") + } + if !utf8.Valid(zoneBytes) { + return nil, offset, fmt.Errorf("zone is not valid UTF-8") + } + offset += zoneLen + return &net.UDPAddr{IP: ip, Port: int(port), Zone: string(zoneBytes)}, offset, nil +} + +type V2BinaryUDPPacketReadWriter struct { + conn *wire.Conn +} + +func NewV2BinaryUDPPacketReadWriter(rw io.ReadWriter) *V2BinaryUDPPacketReadWriter { + return &V2BinaryUDPPacketReadWriter{conn: wire.NewConn(rw)} +} + +func (rw *V2BinaryUDPPacketReadWriter) ReadMsg() (Message, error) { + frame, err := rw.conn.ReadFrame() + if err != nil { + return nil, err + } + if isV2MessageType(frame, V2TypeUDPPacketBinary) { + return decodeV2BinaryUDPPacketFrame(frame) + } + if isV2MessageType(frame, V2TypeUDPPacket) { + return nil, fmt.Errorf("received JSON UDP packet after binary codec negotiation") + } + return DecodeV2MessageFrame(frame) +} + +func (rw *V2BinaryUDPPacketReadWriter) ReadMsgInto(out Message) error { + frame, err := rw.conn.ReadFrame() + if err != nil { + return err + } + if packetOut, ok := out.(*UDPPacket); ok { + if !isV2MessageType(frame, V2TypeUDPPacketBinary) { + return unexpectedV2UDPPacketType(frame) + } + packet, err := decodeV2BinaryUDPPacketFrame(frame) + if err != nil { + return err + } + *packetOut = *packet + return nil + } + return DecodeV2MessageFrameInto(frame, out) +} + +func (rw *V2BinaryUDPPacketReadWriter) WriteMsg(message Message) error { + var packet *UDPPacket + switch typed := message.(type) { + case *UDPPacket: + packet = typed + case UDPPacket: + packet = &typed + default: + frame, err := EncodeV2MessageFrame(message) + if err != nil { + return err + } + return rw.conn.WriteFrame(frame) + } + body, err := EncodeUDPPacketBinary(packet) + if err != nil { + return err + } + payload := make([]byte, 2+len(body)) + binary.BigEndian.PutUint16(payload[:2], V2TypeUDPPacketBinary) + copy(payload[2:], body) + return rw.conn.WriteFrame(&wire.Frame{Type: wire.FrameTypeMessage, Payload: payload}) +} + +func decodeV2BinaryUDPPacketFrame(frame *wire.Frame) (*UDPPacket, error) { + if frame.Type != wire.FrameTypeMessage { + return nil, fmt.Errorf("unexpected frame type %d, want %d", frame.Type, wire.FrameTypeMessage) + } + if len(frame.Payload) < 2 { + return nil, fmt.Errorf("message frame payload too short") + } + if binary.BigEndian.Uint16(frame.Payload[:2]) != V2TypeUDPPacketBinary { + return nil, unexpectedV2UDPPacketType(frame) + } + return DecodeUDPPacketBinary(frame.Payload[2:]) +} + +func isV2MessageType(frame *wire.Frame, typeID uint16) bool { + return frame.Type == wire.FrameTypeMessage && len(frame.Payload) >= 2 && binary.BigEndian.Uint16(frame.Payload[:2]) == typeID +} + +func unexpectedV2UDPPacketType(frame *wire.Frame) error { + if frame.Type != wire.FrameTypeMessage { + return fmt.Errorf("unexpected frame type %d, want %d", frame.Type, wire.FrameTypeMessage) + } + if len(frame.Payload) < 2 { + return fmt.Errorf("message frame payload too short") + } + typeID := binary.BigEndian.Uint16(frame.Payload[:2]) + if typeID == V2TypeUDPPacket { + return fmt.Errorf("received JSON UDP packet after binary codec negotiation") + } + return fmt.Errorf("unexpected message type %d, want %d", typeID, V2TypeUDPPacketBinary) +} + +// NewUDPPacketReadWriter selects the negotiated packet codec without changing +// the framing or codecs used by non-UDP messages on the work connection. +func NewUDPPacketReadWriter(rw io.ReadWriter, wireProtocol, udpPacketCodec string) (ReadWriter, error) { + switch wireProtocol { + case "", wire.ProtocolV1: + if udpPacketCodec != "" { + return nil, fmt.Errorf("UDP packet codec %q requires wire protocol v2", udpPacketCodec) + } + return NewV1ReadWriter(rw), nil + case wire.ProtocolV2: + switch udpPacketCodec { + case "": + return NewV2ReadWriter(rw), nil + case wire.UDPPacketCodecBinary: + return NewV2BinaryUDPPacketReadWriter(rw), nil + default: + return nil, fmt.Errorf("unsupported UDP packet codec %q", udpPacketCodec) + } + default: + return nil, fmt.Errorf("unsupported wire protocol %q", wireProtocol) + } +} diff --git a/pkg/msg/udp_binary_test.go b/pkg/msg/udp_binary_test.go new file mode 100644 index 00000000..7dac31fb --- /dev/null +++ b/pkg/msg/udp_binary_test.go @@ -0,0 +1,248 @@ +// Copyright 2026 The frp Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 + +package msg + +import ( + "bytes" + "encoding/binary" + "net" + "strconv" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/fatedier/frp/pkg/proto/wire" +) + +func TestUDPPacketBinaryRoundTrip(t *testing.T) { + payload := bytes.Repeat([]byte{0xa5}, 1472) + in := &UDPPacket{ + Content: payload, + LocalAddr: &net.UDPAddr{ + IP: net.ParseIP("2001:db8::1"), + Port: 1234, + Zone: "en0", + }, + RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321}, + } + body, err := EncodeUDPPacketBinary(in) + require.NoError(t, err) + out, err := DecodeUDPPacketBinary(body) + require.NoError(t, err) + require.Equal(t, in.Content, out.Content) + require.Equal(t, in.LocalAddr.String(), out.LocalAddr.String()) + require.Equal(t, in.RemoteAddr.String(), out.RemoteAddr.String()) + body[len(body)-1] ^= 0xff + body[25] ^= 0xff + require.Equal(t, byte(0xa5), out.Content[len(out.Content)-1], "decoded payload must own frame bytes") + require.Equal(t, byte(203), out.RemoteAddr.IP.To4()[0], "decoded address must own frame bytes") +} + +func TestUDPPacketBinarySizesAndOptionalLocalAddress(t *testing.T) { + for _, size := range []int{0, 32, 128, 512, 1200, 1472, 4096, 49107, 65507} { + t.Run(strconv.Itoa(size), func(t *testing.T) { + in := &UDPPacket{ + Content: bytes.Repeat([]byte{byte(size)}, size), + RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321}, + } + body, err := EncodeUDPPacketBinary(in) + require.NoError(t, err) + out, err := DecodeUDPPacketBinary(body) + require.NoError(t, err) + require.Equal(t, len(in.Content), len(out.Content)) + if size == 0 { + require.Empty(t, out.Content) + } else { + require.Equal(t, in.Content, out.Content) + } + }) + } +} + +func TestUDPPacketBinaryMalformed(t *testing.T) { + valid, err := EncodeUDPPacketBinary(&UDPPacket{ + Content: []byte("payload"), + RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321}, + }) + require.NoError(t, err) + tests := [][]byte{ + {0x80, 0, 0}, + {0x02, 4, 1, 2}, + {0x02, 4, 1, 2, 3, 4, 0xd4}, + append(append([]byte(nil), valid...), 0), + } + for _, malformed := range tests { + _, err := DecodeUDPPacketBinary(malformed) + require.Error(t, err) + } + _, err = DecodeUDPPacketBinary([]byte{0, 0, 0}) + require.ErrorContains(t, err, "missing remote address") + payloadLengthOffset := len(valid) - len("payload") - 2 + invalidPayloadLength := append([]byte(nil), valid...) + binary.BigEndian.PutUint16(invalidPayloadLength[payloadLengthOffset:payloadLengthOffset+2], 0xffff) + _, err = DecodeUDPPacketBinary(invalidPayloadLength) + require.ErrorContains(t, err, "payload length") + truncatedPayload := append([]byte(nil), valid[:payloadLengthOffset+2]...) + binary.BigEndian.PutUint16(truncatedPayload[payloadLengthOffset:payloadLengthOffset+2], 1) + _, err = DecodeUDPPacketBinary(truncatedPayload) + require.ErrorContains(t, err, "truncated UDP payload") + _, err = DecodeUDPPacketBinary(make([]byte, wire.DefaultMaxFramePayloadSize)) + require.ErrorContains(t, err, "frame payload length") + + badIPv4Zone := []byte{2, 4, 203, 0, 113, 9, 0xd4, 0x31, 1, 'z', 0, 0} + _, err = DecodeUDPPacketBinary(badIPv4Zone) + require.ErrorContains(t, err, "IPv4 zone") + badFamily := []byte{2, 9, 0, 0} + _, err = DecodeUDPPacketBinary(badFamily) + require.ErrorContains(t, err, "unknown address family") + badUTF8 := make([]byte, 0, 24) + badUTF8 = append(badUTF8, 2, 6) + badUTF8 = append(badUTF8, make([]byte, 16)...) + badUTF8 = append(badUTF8, 0, 1, 1, 0xff, 0, 0) + _, err = DecodeUDPPacketBinary(badUTF8) + require.ErrorContains(t, err, "UTF-8") +} + +func TestUDPPacketBinaryEncodeRejectsInvalidPackets(t *testing.T) { + _, err := EncodeUDPPacketBinary(&UDPPacket{}) + require.ErrorContains(t, err, "missing remote address") + _, err = EncodeUDPPacketBinary(&UDPPacket{ + LocalAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 1234}, + }) + require.ErrorContains(t, err, "missing remote address") + _, err = EncodeUDPPacketBinary(&UDPPacket{ + Content: make([]byte, MaxUDPPayloadSize+1), + RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321}, + }) + require.ErrorContains(t, err, "exceeds limit") + _, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 1, Zone: "bad"}}) + require.ErrorContains(t, err, "IPv4 zone") + _, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 1, Zone: string(bytes.Repeat([]byte{'z'}, 256))}}) + require.ErrorContains(t, err, "zone exceeds") + _, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 1, Zone: string([]byte{0xff})}}) + require.ErrorContains(t, err, "UTF-8") + _, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{Port: -1}}) + require.ErrorContains(t, err, "port out of range") + _, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{Port: 65536}}) + require.ErrorContains(t, err, "port out of range") + _, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{IP: net.IP{1, 2, 3}}}) + require.ErrorContains(t, err, "invalid IP") + _, err = EncodeUDPPacketBinary(&UDPPacket{ + Content: make([]byte, MaxUDPPayloadSize), + RemoteAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Zone: string(bytes.Repeat([]byte{'z'}, 255))}, + }) + require.ErrorContains(t, err, "frame payload length") +} + +func TestV2BinaryUDPPacketReadWriterPreservesOtherMessages(t *testing.T) { + var buf bytes.Buffer + rw := NewV2BinaryUDPPacketReadWriter(&buf) + in := &UDPPacket{Content: []byte("udp"), RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321}} + require.NoError(t, rw.WriteMsg(in)) + require.NoError(t, rw.WriteMsg(&Ping{Timestamp: 7})) + frameConn := wire.NewConn(&buf) + frame, err := frameConn.ReadFrame() + require.NoError(t, err) + require.Equal(t, V2TypeUDPPacketBinary, binary.BigEndian.Uint16(frame.Payload[:2])) + frame, err = frameConn.ReadFrame() + require.NoError(t, err) + require.Equal(t, V2TypePing, binary.BigEndian.Uint16(frame.Payload[:2])) +} + +func TestV2BinaryUDPPacketReadWriterRoundTripAndCodecInvariant(t *testing.T) { + in := &UDPPacket{Content: []byte("udp"), RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321}} + var binaryStream bytes.Buffer + binaryWriter, err := NewUDPPacketReadWriter(&binaryStream, wire.ProtocolV2, wire.UDPPacketCodecBinary) + require.NoError(t, err) + require.NoError(t, binaryWriter.WriteMsg(in)) + binaryReader, err := NewUDPPacketReadWriter(&binaryStream, wire.ProtocolV2, wire.UDPPacketCodecBinary) + require.NoError(t, err) + out, err := binaryReader.ReadMsg() + require.NoError(t, err) + require.Equal(t, in.Content, out.(*UDPPacket).Content) + + for _, read := range []func(ReadWriter) error{ + func(rw ReadWriter) error { + _, err := rw.ReadMsg() + return err + }, + func(rw ReadWriter) error { + return rw.ReadMsgInto(&UDPPacket{}) + }, + } { + var jsonStream bytes.Buffer + require.NoError(t, NewReadWriter(&jsonStream, wire.ProtocolV2).WriteMsg(in)) + negotiatedReader, err := NewUDPPacketReadWriter(&jsonStream, wire.ProtocolV2, wire.UDPPacketCodecBinary) + require.NoError(t, err) + require.ErrorContains(t, read(negotiatedReader), "JSON UDP packet after binary codec negotiation") + } + + var fallbackStream bytes.Buffer + fallbackWriter, err := NewUDPPacketReadWriter(&fallbackStream, wire.ProtocolV2, "") + require.NoError(t, err) + require.NoError(t, fallbackWriter.WriteMsg(in)) + frame, err := wire.NewConn(&fallbackStream).ReadFrame() + require.NoError(t, err) + require.Equal(t, V2TypeUDPPacket, binary.BigEndian.Uint16(frame.Payload[:2])) +} + +func TestNewUDPPacketReadWriterDefaultProtocolUsesV1(t *testing.T) { + var stream bytes.Buffer + rw, err := NewUDPPacketReadWriter(&stream, "", "") + require.NoError(t, err) + require.IsType(t, &V1ReadWriter{}, rw) + require.NoError(t, rw.WriteMsg(&UDPPacket{Content: []byte("legacy")})) + require.Equal(t, TypeUDPPacket, stream.Bytes()[0]) +} + +func TestNewUDPPacketReadWriterRejectsInvalidSelection(t *testing.T) { + for _, tc := range []struct { + name string + wireProtocol string + udpPacketCodec string + errorSubstring string + }{ + { + name: "binary codec over v1", + wireProtocol: wire.ProtocolV1, + udpPacketCodec: wire.UDPPacketCodecBinary, + errorSubstring: "requires wire protocol v2", + }, + { + name: "binary codec over default protocol", + udpPacketCodec: wire.UDPPacketCodecBinary, + errorSubstring: "requires wire protocol v2", + }, + { + name: "unknown v2 codec", + wireProtocol: wire.ProtocolV2, + udpPacketCodec: "unknown", + errorSubstring: "unsupported UDP packet codec", + }, + { + name: "unknown wire protocol", + wireProtocol: "unknown", + errorSubstring: "unsupported wire protocol", + }, + } { + t.Run(tc.name, func(t *testing.T) { + rw, err := NewUDPPacketReadWriter(&bytes.Buffer{}, tc.wireProtocol, tc.udpPacketCodec) + require.Nil(t, rw) + require.ErrorContains(t, err, tc.errorSubstring) + }) + } +} + +func FuzzDecodeUDPPacketBinary(f *testing.F) { + f.Add([]byte{0, 0, 0}) + f.Add([]byte{2, 4, 203, 0, 113, 9, 0xd4, 0x31, 0, 0, 1}) + f.Fuzz(func(t *testing.T, body []byte) { + _, _ = DecodeUDPPacketBinary(body) + }) +} diff --git a/pkg/msg/wire_v2.go b/pkg/msg/wire_v2.go index 8d2cd88d..f3da2330 100644 --- a/pkg/msg/wire_v2.go +++ b/pkg/msg/wire_v2.go @@ -43,6 +43,7 @@ const ( V2TypeNatHoleResp uint16 = 16 V2TypeNatHoleSid uint16 = 17 V2TypeNatHoleReport uint16 = 18 + V2TypeUDPPacketBinary uint16 = 19 ) var v2MsgTypeMap = map[uint16]any{ diff --git a/pkg/msg/wire_v2_test.go b/pkg/msg/wire_v2_test.go index f6f25e55..ea6e8d5d 100644 --- a/pkg/msg/wire_v2_test.go +++ b/pkg/msg/wire_v2_test.go @@ -84,6 +84,9 @@ func TestV2MessageTypeIDsAreStable(t *testing.T) { require.Equal(t, uint16(16), V2TypeNatHoleResp) require.Equal(t, uint16(17), V2TypeNatHoleSid) require.Equal(t, uint16(18), V2TypeNatHoleReport) + require.Equal(t, uint16(19), V2TypeUDPPacketBinary) + _, registered := v2MsgTypeMap[V2TypeUDPPacketBinary] + require.False(t, registered, "binary UDP has a dedicated codec and must not alter generic type registry") } func TestV2MessageFrameEncoding(t *testing.T) { diff --git a/pkg/proto/udp/udp.go b/pkg/proto/udp/udp.go index 70b51900..ec3bd94f 100644 --- a/pkg/proto/udp/udp.go +++ b/pkg/proto/udp/udp.go @@ -63,9 +63,13 @@ func ForwardUserConn(udpConn *net.UDPConn, readCh <-chan *msg.UDPPacket, sendCh // NewUDPPacket copies buf[:n], so the read buffer can be reused udpMsg := NewUDPPacket(buf[:n], nil, remoteAddr) - select { - case sendCh <- udpMsg: - default: + if err = errors.PanicToError(func() { + select { + case sendCh <- udpMsg: + default: + } + }); err != nil { + return } } } diff --git a/pkg/proto/udp/udp_test.go b/pkg/proto/udp/udp_test.go index 1a7f0091..b959a26a 100644 --- a/pkg/proto/udp/udp_test.go +++ b/pkg/proto/udp/udp_test.go @@ -1,9 +1,13 @@ package udp import ( + "net" "testing" + "time" "github.com/stretchr/testify/require" + + "github.com/fatedier/frp/pkg/msg" ) func TestUdpPacket(t *testing.T) { @@ -16,3 +20,33 @@ func TestUdpPacket(t *testing.T) { require.NoError(err) require.EqualValues(buf, newBuf) } + +func TestForwardUserConnReturnsWhenSendChannelIsClosed(t *testing.T) { + listener, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + require.NoError(t, err) + t.Cleanup(func() { _ = listener.Close() }) + + readCh := make(chan *msg.UDPPacket) + sendCh := make(chan *msg.UDPPacket) + close(sendCh) + t.Cleanup(func() { close(readCh) }) + + done := make(chan struct{}) + go func() { + ForwardUserConn(listener, readCh, sendCh, 1500) + close(done) + }() + + sender, err := net.DialUDP("udp4", nil, listener.LocalAddr().(*net.UDPAddr)) + require.NoError(t, err) + t.Cleanup(func() { _ = sender.Close() }) + + _, err = sender.Write([]byte("trigger")) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("ForwardUserConn did not return after sending to a closed channel") + } +} diff --git a/pkg/proto/wire/crypto.go b/pkg/proto/wire/crypto.go index 69aedc69..38916a28 100644 --- a/pkg/proto/wire/crypto.go +++ b/pkg/proto/wire/crypto.go @@ -68,7 +68,8 @@ func NewServerHello(clientHello ClientHello) (ServerHello, error) { return ServerHello{ Selected: ServerSelection{ Message: MessageSelection{ - Codec: MessageCodecJSON, + Codec: MessageCodecJSON, + UDPPacketCodec: selectUDPPacketCodec(clientHello.Capabilities.Message.UDPPacketCodecs), }, Crypto: CryptoSelection{ Algorithm: algorithm, @@ -92,6 +93,15 @@ func ValidateServerHelloForClient(clientHello ClientHello, serverHello ServerHel if serverHello.Selected.Message.Codec != MessageCodecJSON { return fmt.Errorf("unsupported selected message codec: %s", serverHello.Selected.Message.Codec) } + udpPacketCodec := serverHello.Selected.Message.UDPPacketCodec + if udpPacketCodec != "" { + if udpPacketCodec != UDPPacketCodecBinary { + return fmt.Errorf("unsupported selected UDP packet codec: %s", udpPacketCodec) + } + if !Supports(clientHello.Capabilities.Message.UDPPacketCodecs, udpPacketCodec) { + return fmt.Errorf("selected UDP packet codec was not advertised by client: %s", udpPacketCodec) + } + } cryptoSelection := serverHello.Selected.Crypto if !IsSupportedAEADAlgorithm(cryptoSelection.Algorithm) { return fmt.Errorf("unknown selected crypto algorithm: %s", cryptoSelection.Algorithm) @@ -105,6 +115,13 @@ func ValidateServerHelloForClient(clientHello ClientHello, serverHello ServerHel return nil } +func selectUDPPacketCodec(codecs []string) string { + if Supports(codecs, UDPPacketCodecBinary) { + return UDPPacketCodecBinary + } + return "" +} + func NewCryptoContext(algorithm string, clientHelloPayload, serverHelloPayload []byte) *CryptoContext { return &CryptoContext{ Algorithm: algorithm, diff --git a/pkg/proto/wire/wire.go b/pkg/proto/wire/wire.go index 47bf5984..5a109d67 100644 --- a/pkg/proto/wire/wire.go +++ b/pkg/proto/wire/wire.go @@ -36,6 +36,7 @@ const ( FrameTypeMessage uint16 = 16 MessageCodecJSON = "json" + UDPPacketCodecBinary = "binary-v1" DefaultMaxFramePayloadSize = 64 * 1024 MagicV2 = "FRP\x00\x02\r\n" @@ -182,7 +183,8 @@ type ClientCapabilities struct { } type MessageCapabilities struct { - Codecs []string `json:"codecs,omitempty"` + Codecs []string `json:"codecs,omitempty"` + UDPPacketCodecs []string `json:"udpPacketCodecs,omitempty"` } type CryptoCapabilities struct { @@ -201,7 +203,8 @@ type ServerSelection struct { } type MessageSelection struct { - Codec string `json:"codec,omitempty"` + Codec string `json:"codec,omitempty"` + UDPPacketCodec string `json:"udpPacketCodec,omitempty"` } type CryptoSelection struct { @@ -214,7 +217,8 @@ func clientHelloWithCryptoRandom(bootstrap BootstrapInfo, clientRandom []byte) C Bootstrap: bootstrap, Capabilities: ClientCapabilities{ Message: MessageCapabilities{ - Codecs: []string{MessageCodecJSON}, + Codecs: []string{MessageCodecJSON}, + UDPPacketCodecs: []string{UDPPacketCodecBinary}, }, Crypto: CryptoCapabilities{ Algorithms: PreferredAEADAlgorithms(), diff --git a/pkg/proto/wire/wire_test.go b/pkg/proto/wire/wire_test.go index b564f712..b1c1b062 100644 --- a/pkg/proto/wire/wire_test.go +++ b/pkg/proto/wire/wire_test.go @@ -148,10 +148,40 @@ func TestNewServerHelloSelectsFirstSupportedAEADAlgorithm(t *testing.T) { serverHello, err := NewServerHello(hello) require.NoError(t, err) require.Equal(t, MessageCodecJSON, serverHello.Selected.Message.Codec) + require.Equal(t, UDPPacketCodecBinary, serverHello.Selected.Message.UDPPacketCodec) require.Equal(t, AEADAlgorithmXChaCha20Poly1305, serverHello.Selected.Crypto.Algorithm) require.Len(t, serverHello.Selected.Crypto.ServerRandom, CryptoRandomSize) } +func TestUDPPacketCodecNegotiationFallbackAndValidation(t *testing.T) { + hello := mustClientHello(t, BootstrapInfo{}) + serverHello, err := NewServerHello(hello) + require.NoError(t, err) + require.Equal(t, UDPPacketCodecBinary, serverHello.Selected.Message.UDPPacketCodec) + require.NoError(t, ValidateServerHelloForClient(hello, serverHello)) + + legacyHello := hello + legacyHello.Capabilities.Message.UDPPacketCodecs = nil + legacyServerHello, err := NewServerHello(legacyHello) + require.NoError(t, err) + require.Empty(t, legacyServerHello.Selected.Message.UDPPacketCodec) + require.NoError(t, ValidateServerHelloForClient(legacyHello, legacyServerHello)) + + unknownOffer := hello + unknownOffer.Capabilities.Message.UDPPacketCodecs = []string{"unknown"} + unknownServerHello, err := NewServerHello(unknownOffer) + require.NoError(t, err) + require.Empty(t, unknownServerHello.Selected.Message.UDPPacketCodec) + + rejected := serverHello + rejected.Selected.Message.UDPPacketCodec = "unknown" + require.ErrorContains(t, ValidateServerHelloForClient(hello, rejected), "unsupported selected UDP packet codec") + + unadvertised := serverHello + unadvertised.Selected.Message.UDPPacketCodec = UDPPacketCodecBinary + require.ErrorContains(t, ValidateServerHelloForClient(legacyHello, unadvertised), "was not advertised") +} + func TestNewClientCryptoContextValidatesServerHello(t *testing.T) { hello := mustClientHello(t, BootstrapInfo{}) serverHello, err := NewServerHello(hello) diff --git a/pkg/ssh/server.go b/pkg/ssh/server.go index c042b9c1..8a69fc30 100644 --- a/pkg/ssh/server.go +++ b/pkg/ssh/server.go @@ -70,6 +70,7 @@ type TunnelServer struct { sshConn *ssh.ServerConn sc *ssh.ServerConfig firstChannel ssh.Channel + firstChannelMu sync.Mutex vc *virtual.Client peerServerListener *netpkg.InternalListener @@ -191,6 +192,8 @@ func (s *TunnelServer) Run() error { } func (s *TunnelServer) writeToClient(data string) { + s.firstChannelMu.Lock() + defer s.firstChannelMu.Unlock() if s.firstChannel == nil { return } @@ -304,9 +307,11 @@ func (s *TunnelServer) handleNewChannel(channel ssh.NewChannel, extraPayloadCh c if err != nil { return } + s.firstChannelMu.Lock() if s.firstChannel == nil { s.firstChannel = ch } + s.firstChannelMu.Unlock() go s.keepAlive(ch) for req := range reqs { diff --git a/pkg/ssh/server_test.go b/pkg/ssh/server_test.go index 78421a55..56b1ba5b 100644 --- a/pkg/ssh/server_test.go +++ b/pkg/ssh/server_test.go @@ -16,7 +16,11 @@ package ssh import ( "encoding/binary" + "io" + "sync" + "sync/atomic" "testing" + "time" "github.com/stretchr/testify/require" cryptossh "golang.org/x/crypto/ssh" @@ -69,3 +73,43 @@ func TestParseExecPayloadRejectsMalformedPayloads(t *testing.T) { }) } } + +type trackingChannel struct { + active atomic.Int32 + concurrent atomic.Bool +} + +func (c *trackingChannel) Read([]byte) (int, error) { return 0, io.EOF } + +func (c *trackingChannel) Write(p []byte) (int, error) { + if c.active.Add(1) != 1 { + c.concurrent.Store(true) + } + time.Sleep(time.Millisecond) + c.active.Add(-1) + return len(p), nil +} + +func (c *trackingChannel) Close() error { return nil } +func (c *trackingChannel) CloseWrite() error { return nil } +func (c *trackingChannel) SendRequest(string, bool, []byte) (bool, error) { return false, nil } +func (c *trackingChannel) Stderr() io.ReadWriter { return nil } + +func TestWriteToClientSerializesChannelWrites(t *testing.T) { + channel := &trackingChannel{} + s := &TunnelServer{firstChannel: channel} + start := make(chan struct{}) + var wg sync.WaitGroup + for range 8 { + wg.Go(func() { + <-start + s.writeToClient("message") + }) + } + close(start) + wg.Wait() + + if channel.concurrent.Load() { + t.Fatal("channel writes were concurrent") + } +} diff --git a/pkg/util/limit/limiter.go b/pkg/util/limit/limiter.go new file mode 100644 index 00000000..43ba7434 --- /dev/null +++ b/pkg/util/limit/limiter.go @@ -0,0 +1,37 @@ +// Copyright 2026 The frp Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package limit + +import ( + "fmt" + + "golang.org/x/time/rate" +) + +// NewBandwidthLimiter creates a limiter whose rate preserves the configured +// byte limit while keeping the burst representable as an int on all targets. +func NewBandwidthLimiter(bytes int64) *rate.Limiter { + if bytes <= 0 { + return nil + } + + maxInt := int64(^uint(0) >> 1) + burst := min(bytes, maxInt) + return rate.NewLimiter(rate.Limit(float64(bytes)), int(burst)) +} + +func invalidBurstError(burst int) error { + return fmt.Errorf("invalid limiter burst: %d", burst) +} diff --git a/pkg/util/limit/limiter_test.go b/pkg/util/limit/limiter_test.go new file mode 100644 index 00000000..f245bcfc --- /dev/null +++ b/pkg/util/limit/limiter_test.go @@ -0,0 +1,65 @@ +// Copyright 2026 The frp Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package limit + +import ( + "bytes" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/time/rate" +) + +func TestNewBandwidthLimiterClampsBurstToTargetInt(t *testing.T) { + const bytesPerSecond = int64(1 << 31) + + limiter := NewBandwidthLimiter(bytesPerSecond) + require.NotNil(t, limiter) + + wantBurst := bytesPerSecond + maxInt := int64(^uint(0) >> 1) + if wantBurst > maxInt { + wantBurst = maxInt + } + require.Equal(t, int(wantBurst), limiter.Burst()) + require.Equal(t, rate.Limit(float64(bytesPerSecond)), limiter.Limit()) +} + +func TestNewBandwidthLimiterDisablesNonPositiveLimit(t *testing.T) { + require.Nil(t, NewBandwidthLimiter(0)) + require.Nil(t, NewBandwidthLimiter(-1)) +} + +func TestReaderAndWriterRejectInvalidBurst(t *testing.T) { + for _, burst := range []int{0, -1} { + t.Run("reader/"+strconv.Itoa(burst), func(t *testing.T) { + reader := NewReader(strings.NewReader("payload"), rate.NewLimiter(rate.Limit(1), burst)) + n, err := reader.Read(make([]byte, 1)) + require.Zero(t, n) + require.EqualError(t, err, "invalid limiter burst: "+strconv.Itoa(burst)) + }) + + t.Run("writer/"+strconv.Itoa(burst), func(t *testing.T) { + var dst bytes.Buffer + writer := NewWriter(&dst, rate.NewLimiter(rate.Limit(1), burst)) + n, err := writer.Write([]byte("payload")) + require.Zero(t, n) + require.EqualError(t, err, "invalid limiter burst: "+strconv.Itoa(burst)) + require.Empty(t, dst.Bytes()) + }) + } +} diff --git a/pkg/util/limit/reader.go b/pkg/util/limit/reader.go index efa828f4..eccca5a5 100644 --- a/pkg/util/limit/reader.go +++ b/pkg/util/limit/reader.go @@ -35,6 +35,12 @@ func NewReader(r io.Reader, limiter *rate.Limiter) *Reader { func (r *Reader) Read(p []byte) (n int, err error) { b := r.limiter.Burst() + if b <= 0 { + if len(p) == 0 { + return 0, nil + } + return 0, invalidBurstError(b) + } if b < len(p) { p = p[:b] } diff --git a/pkg/util/limit/writer.go b/pkg/util/limit/writer.go index 5256d1e2..56357228 100644 --- a/pkg/util/limit/writer.go +++ b/pkg/util/limit/writer.go @@ -34,8 +34,15 @@ func NewWriter(w io.Writer, limiter *rate.Limiter) *Writer { } func (w *Writer) Write(p []byte) (n int, err error) { + if len(p) == 0 { + return 0, nil + } + var nn int b := w.limiter.Burst() + if b <= 0 { + return 0, invalidBurstError(b) + } for { end := len(p) if end == 0 { diff --git a/pkg/util/version/version.go b/pkg/util/version/version.go index ca5ec108..5c232712 100644 --- a/pkg/util/version/version.go +++ b/pkg/util/version/version.go @@ -14,7 +14,7 @@ package version -var version = "0.70.1" +var version = "0.71.0" func Full() string { return version diff --git a/pkg/util/xlog/xlog.go b/pkg/util/xlog/xlog.go index f8f4116a..1021d44a 100644 --- a/pkg/util/xlog/xlog.go +++ b/pkg/util/xlog/xlog.go @@ -96,21 +96,21 @@ func (l *Logger) Spawn() *Logger { } func (l *Logger) Errorf(format string, v ...any) { - log.Logger.Errorf(l.prefixString+format, v...) + log.Logger.WithPrefix(l.prefixString).Errorf(format, v...) } func (l *Logger) Warnf(format string, v ...any) { - log.Logger.Warnf(l.prefixString+format, v...) + log.Logger.WithPrefix(l.prefixString).Warnf(format, v...) } func (l *Logger) Infof(format string, v ...any) { - log.Logger.Infof(l.prefixString+format, v...) + log.Logger.WithPrefix(l.prefixString).Infof(format, v...) } func (l *Logger) Debugf(format string, v ...any) { - log.Logger.Debugf(l.prefixString+format, v...) + log.Logger.WithPrefix(l.prefixString).Debugf(format, v...) } func (l *Logger) Tracef(format string, v ...any) { - log.Logger.Tracef(l.prefixString+format, v...) + log.Logger.WithPrefix(l.prefixString).Tracef(format, v...) } diff --git a/pkg/util/xlog/xlog_test.go b/pkg/util/xlog/xlog_test.go new file mode 100644 index 00000000..13d006ef --- /dev/null +++ b/pkg/util/xlog/xlog_test.go @@ -0,0 +1,76 @@ +// Copyright 2026 The frp Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package xlog + +import ( + "bytes" + "testing" + + goliblog "github.com/fatedier/golib/log" + "github.com/stretchr/testify/require" + + frplog "github.com/fatedier/frp/pkg/util/log" +) + +func TestPrefixIsNotPartOfFormatString(t *testing.T) { + tests := []struct { + name string + log func(*Logger) + }{ + {name: "error", log: func(xl *Logger) { xl.Errorf("value [%s]", "ok") }}, + {name: "warn", log: func(xl *Logger) { xl.Warnf("value [%s]", "ok") }}, + {name: "info", log: func(xl *Logger) { xl.Infof("value [%s]", "ok") }}, + {name: "debug", log: func(xl *Logger) { xl.Debugf("value [%s]", "ok") }}, + {name: "trace", log: func(xl *Logger) { xl.Tracef("value [%s]", "ok") }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output := captureLogs(t) + + tt.log(New().AppendPrefix("%1000000s")) + + require.Contains(t, output.String(), "[%1000000s] value [ok]") + require.Less(t, output.Len(), 1024) + }) + } +} + +func TestFormattingSemanticsArePreserved(t *testing.T) { + output := captureLogs(t) + xl := New().AppendPrefix("run") + + xl.Infof("%[2]s %[1]s", "first", "second") + xl.Infof("100% complete") + + require.Contains(t, output.String(), "[run] second first") + require.Contains(t, output.String(), "[run] 100% complete") +} + +func captureLogs(t *testing.T) *bytes.Buffer { + t.Helper() + + output := bytes.NewBuffer(nil) + oldLogger := frplog.Logger + frplog.Logger = goliblog.New( + goliblog.WithOutput(output), + goliblog.WithLevel(goliblog.TraceLevel), + goliblog.WithCaller(false), + ) + t.Cleanup(func() { + frplog.Logger = oldLogger + }) + return output +} diff --git a/server/control.go b/server/control.go index 6a5d3ce1..8009d8ac 100644 --- a/server/control.go +++ b/server/control.go @@ -17,6 +17,7 @@ package server import ( "context" "fmt" + "math" "net" "runtime/debug" "sync" @@ -45,6 +46,8 @@ type ControlID uint64 var nextControlID atomic.Uint64 +const workConnPoolCapacityOffset = 10 + type controlEntry struct { ctl *Control id ControlID @@ -283,7 +286,7 @@ func (cm *ControlManager) GetByID(runID string) (ctl *Control, ok bool) { // admitVisitorByRunID commits a visitor admission against the current running // control while its run and lifecycle ownership are held. The callback must // only perform the in-memory, buffered visitor admission. -func (cm *ControlManager) admitVisitorByRunID(runID string, admit func(user string) error) (bool, error) { +func (cm *ControlManager) admitVisitorByRunID(runID string, admit func(user, wireProtocol, udpPacketCodec string) error) (bool, error) { entry, ok := cm.lockCurrentRun(runID, false) if !ok { return false, nil @@ -296,7 +299,7 @@ func (cm *ControlManager) admitVisitorByRunID(runID string, admit func(user stri if ctl.state != controlStateRunning { return false, nil } - return true, admit(ctl.sessionCtx.LoginMsg.User) + return true, admit(ctl.sessionCtx.LoginMsg.User, ctl.sessionCtx.WireProtocol, ctl.sessionCtx.UDPPacketCodec) } // RegisterWorkConn transfers conn to ctl only if ctl is still the current @@ -368,7 +371,8 @@ type SessionContext struct { // server configuration ServerCfg *v1.ServerConfig // negotiated wire protocol for this client session - WireProtocol string + WireProtocol string + UDPPacketCodec string } type controlState uint8 @@ -430,10 +434,27 @@ type Control struct { } func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, error) { - poolCount := min(sessionCtx.LoginMsg.PoolCount, int(sessionCtx.ServerCfg.Transport.MaxPoolCount)) + if sessionCtx.LoginMsg.PoolCount < 0 { + return nil, fmt.Errorf("invalid pool count %d, must be non-negative", sessionCtx.LoginMsg.PoolCount) + } + if sessionCtx.ServerCfg.Transport.MaxPoolCount < 0 { + return nil, fmt.Errorf( + "invalid max pool count %d, must be non-negative", + sessionCtx.ServerCfg.Transport.MaxPoolCount, + ) + } + effectivePoolCount := min(int64(sessionCtx.LoginMsg.PoolCount), sessionCtx.ServerCfg.Transport.MaxPoolCount) + maxPoolCountForChannel := int64(math.MaxInt) - int64(workConnPoolCapacityOffset) + if effectivePoolCount > maxPoolCountForChannel { + return nil, fmt.Errorf( + "invalid effective pool count %d, cannot safely add %d for work connection pool capacity", + effectivePoolCount, workConnPoolCapacityOffset, + ) + } + poolCount := int(effectivePoolCount) ctl := &Control{ sessionCtx: sessionCtx, - workConnCh: make(chan *proxy.WorkConn, poolCount+10), + workConnCh: make(chan *proxy.WorkConn, poolCount+workConnPoolCapacityOffset), proxies: make(map[string]proxy.Proxy), poolCount: poolCount, portsUsedNum: 0, @@ -821,6 +842,7 @@ func (ctl *Control) RegisterProxy(pxyMsg *msg.NewProxy) (remoteAddr string, err ServerCfg: ctl.sessionCtx.ServerCfg, EncryptionKey: ctl.sessionCtx.EncryptionKey, WireProtocol: ctl.sessionCtx.WireProtocol, + UDPPacketCodec: ctl.sessionCtx.UDPPacketCodec, }) if err != nil { return remoteAddr, err diff --git a/server/control_test.go b/server/control_test.go index 9dce3d24..965ea5ea 100644 --- a/server/control_test.go +++ b/server/control_test.go @@ -17,6 +17,7 @@ package server import ( "context" "errors" + "math" "net" "os" "sync" @@ -52,6 +53,57 @@ func TestControlPendingReplacementFinishesWithoutStarting(t *testing.T) { require.Equal(t, int64(0), metrics.closedClients()) } +func TestNewControlPoolCountBoundaries(t *testing.T) { + for _, tc := range []struct { + name string + poolCount int + maxPoolCount int64 + wantErr string + wantPoolCount int + wantCapacity int + }{ + {name: "negative pool count below offset", poolCount: -11, maxPoolCount: 5, wantErr: "invalid pool count"}, + {name: "negative pool count at offset", poolCount: -10, maxPoolCount: 5, wantErr: "invalid pool count"}, + {name: "negative pool count", poolCount: -1, maxPoolCount: 5, wantErr: "invalid pool count"}, + {name: "zero pool count", poolCount: 0, maxPoolCount: 5, wantPoolCount: 0, wantCapacity: 10}, + {name: "pool count capped", poolCount: 10, maxPoolCount: 5, wantPoolCount: 5, wantCapacity: 15}, + {name: "maximum int pool count capped", poolCount: math.MaxInt, maxPoolCount: 5, wantPoolCount: 5, wantCapacity: 15}, + {name: "negative maximum", poolCount: 1, maxPoolCount: -1, wantErr: "invalid max pool count"}, + {name: "maximum int64 with small client pool", poolCount: 1, maxPoolCount: math.MaxInt64, wantPoolCount: 1, wantCapacity: 11}, + {name: "maximum int client and server overflow", poolCount: math.MaxInt, maxPoolCount: math.MaxInt64, wantErr: "cannot safely add"}, + } { + t.Run(tc.name, func(t *testing.T) { + conn := newDeadlineReadConn() + msgConn := msg.NewConn(conn, msg.NewV1ReadWriter(conn)) + cfg := &v1.ServerConfig{} + cfg.Transport.MaxPoolCount = tc.maxPoolCount + + ctl, err := NewControl(context.Background(), &SessionContext{ + RC: &controller.ResourceController{}, + PxyManager: proxy.NewManager(), + PluginManager: plugin.NewManager(), + AuthVerifier: auth.AlwaysPassVerifier, + Conn: msgConn, + LoginMsg: &msg.Login{ + RunID: "pool-count-run", + PoolCount: tc.poolCount, + }, + ServerCfg: cfg, + }) + if tc.wantErr != "" { + require.Nil(t, ctl) + require.ErrorContains(t, err, tc.wantErr) + return + } + + require.NoError(t, err) + require.Equal(t, tc.wantPoolCount, ctl.poolCount) + require.Equal(t, tc.wantCapacity, cap(ctl.workConnCh)) + require.NoError(t, ctl.Close()) + }) + } +} + func TestControlRunningReplacementFinishesInWorker(t *testing.T) { clientRegistry := registry.NewClientRegistry() manager := NewControlManager(clientRegistry) diff --git a/server/proxy/proxy.go b/server/proxy/proxy.go index e9ace046..f4e75f0a 100644 --- a/server/proxy/proxy.go +++ b/server/proxy/proxy.go @@ -82,19 +82,20 @@ type Proxy interface { } type BaseProxy struct { - name string - rc *controller.ResourceController - listeners []net.Listener - usedPortsNum int - poolCount int - getWorkConnFn GetWorkConnFn - serverCfg *v1.ServerConfig - encryptionKey []byte - limiter *rate.Limiter - userInfo plugin.UserInfo - loginMsg *msg.Login - configurer v1.ProxyConfigurer - wireProtocol string + name string + rc *controller.ResourceController + listeners []net.Listener + usedPortsNum int + poolCount int + getWorkConnFn GetWorkConnFn + serverCfg *v1.ServerConfig + encryptionKey []byte + limiter *rate.Limiter + userInfo plugin.UserInfo + loginMsg *msg.Login + configurer v1.ProxyConfigurer + wireProtocol string + udpPacketCodec string mu sync.RWMutex xl *xlog.Logger @@ -327,10 +328,18 @@ func (pxy *BaseProxy) handleUserTCPConnection(userConn net.Conn) { func (pxy *BaseProxy) joinUserConnection(local io.ReadWriteCloser, userConn net.Conn, proxyType string, xl *xlog.Logger) (int64, int64, []error) { visitorWireProtocol := wireProtocolFromConn(userConn) - if proxyType == string(v1.ProxyTypeSUDP) && isMixedWireProtocol(pxy.wireProtocol, visitorWireProtocol) { - xl.Infof("bridge mixed SUDP payload codecs, proxy wireProtocol [%s], visitor wireProtocol [%s]", - normalizeWireProtocol(pxy.wireProtocol), normalizeWireProtocol(visitorWireProtocol)) - return joinSUDPMessageBridge(local, userConn, pxy.wireProtocol, visitorWireProtocol, xl) + visitorUDPPacketCodec := udpPacketCodecFromConn(userConn) + if proxyType == string(v1.ProxyTypeSUDP) { + mixed, err := isMixedSUDPPacketEncoding(pxy.wireProtocol, pxy.udpPacketCodec, visitorWireProtocol, visitorUDPPacketCodec) + if err != nil { + return 0, 0, []error{err} + } + if mixed { + xl.Infof("bridge mixed SUDP payload codecs, proxy [%s/%s], visitor [%s/%s]", + normalizeWireProtocol(pxy.wireProtocol), pxy.udpPacketCodec, + normalizeWireProtocol(visitorWireProtocol), visitorUDPPacketCodec) + return joinSUDPMessageBridge(local, userConn, pxy.wireProtocol, pxy.udpPacketCodec, visitorWireProtocol, visitorUDPPacketCodec, xl) + } } return libio.Join(local, userConn) } @@ -339,6 +348,10 @@ type wireProtocolGetter interface { WireProtocol() string } +type udpPacketCodecGetter interface { + UDPPacketCodec() string +} + func wireProtocolFromConn(conn net.Conn) string { if getter, ok := conn.(wireProtocolGetter); ok { return getter.WireProtocol() @@ -346,10 +359,46 @@ func wireProtocolFromConn(conn net.Conn) string { return "" } +func udpPacketCodecFromConn(conn net.Conn) string { + if getter, ok := conn.(udpPacketCodecGetter); ok { + return getter.UDPPacketCodec() + } + return "" +} + func isMixedWireProtocol(left, right string) bool { return normalizeWireProtocol(left) != normalizeWireProtocol(right) } +func isMixedSUDPPacketEncoding(leftWire, leftCodec, rightWire, rightCodec string) (bool, error) { + leftCodec, err := normalizeUDPPacketCodec(leftWire, leftCodec) + if err != nil { + return false, fmt.Errorf("invalid left SUDP packet encoding: %w", err) + } + rightCodec, err = normalizeUDPPacketCodec(rightWire, rightCodec) + if err != nil { + return false, fmt.Errorf("invalid right SUDP packet encoding: %w", err) + } + return normalizeWireProtocol(leftWire) != normalizeWireProtocol(rightWire) || leftCodec != rightCodec, nil +} + +func normalizeUDPPacketCodec(wireProtocol, codec string) (string, error) { + switch wireProtocol { + case "", wire.ProtocolV1: + if codec != "" { + return "", fmt.Errorf("UDP packet codec %q requires wire protocol v2", codec) + } + return "", nil + case wire.ProtocolV2: + if codec == "" || codec == wire.UDPPacketCodecBinary { + return codec, nil + } + return "", fmt.Errorf("unsupported UDP packet codec %q", codec) + default: + return "", fmt.Errorf("unsupported wire protocol %q", wireProtocol) + } +} + func normalizeWireProtocol(wireProtocol string) string { if wireProtocol == wire.ProtocolV2 { return wire.ProtocolV2 @@ -361,13 +410,21 @@ func joinSUDPMessageBridge( proxyConn io.ReadWriteCloser, visitorConn io.ReadWriteCloser, proxyWireProtocol string, + proxyUDPPacketCodec string, visitorWireProtocol string, + visitorUDPPacketCodec string, xl *xlog.Logger, ) (inCount int64, outCount int64, errs []error) { // The mixed bridge decodes and re-encodes messages, so raw framed byte counts // are not available. Count UDP payload bytes and ignore heartbeat traffic. - proxyRW := msg.NewReadWriter(proxyConn, proxyWireProtocol) - visitorRW := msg.NewReadWriter(visitorConn, visitorWireProtocol) + proxyRW, err := msg.NewUDPPacketReadWriter(proxyConn, proxyWireProtocol, proxyUDPPacketCodec) + if err != nil { + return 0, 0, []error{err} + } + visitorRW, err := msg.NewUDPPacketReadWriter(visitorConn, visitorWireProtocol, visitorUDPPacketCodec) + if err != nil { + return 0, 0, []error{err} + } var ( once sync.Once @@ -469,6 +526,7 @@ type Options struct { ServerCfg *v1.ServerConfig EncryptionKey []byte WireProtocol string + UDPPacketCodec string } func NewProxy(ctx context.Context, options *Options) (pxy Proxy, err error) { @@ -478,24 +536,25 @@ func NewProxy(ctx context.Context, options *Options) (pxy Proxy, err error) { var limiter *rate.Limiter limitBytes := configurer.GetBaseConfig().Transport.BandwidthLimit.Bytes() if limitBytes > 0 && configurer.GetBaseConfig().Transport.BandwidthLimitMode == types.BandwidthLimitModeServer { - limiter = rate.NewLimiter(rate.Limit(float64(limitBytes)), int(limitBytes)) + limiter = limit.NewBandwidthLimiter(limitBytes) } basePxy := BaseProxy{ - name: configurer.GetBaseConfig().Name, - rc: options.ResourceController, - listeners: make([]net.Listener, 0), - poolCount: options.PoolCount, - getWorkConnFn: options.GetWorkConnFn, - serverCfg: options.ServerCfg, - encryptionKey: options.EncryptionKey, - limiter: limiter, - xl: xl, - ctx: xlog.NewContext(ctx, xl), - userInfo: options.UserInfo, - loginMsg: options.LoginMsg, - configurer: configurer, - wireProtocol: options.WireProtocol, + name: configurer.GetBaseConfig().Name, + rc: options.ResourceController, + listeners: make([]net.Listener, 0), + poolCount: options.PoolCount, + getWorkConnFn: options.GetWorkConnFn, + serverCfg: options.ServerCfg, + encryptionKey: options.EncryptionKey, + limiter: limiter, + xl: xl, + ctx: xlog.NewContext(ctx, xl), + userInfo: options.UserInfo, + loginMsg: options.LoginMsg, + configurer: configurer, + wireProtocol: options.WireProtocol, + udpPacketCodec: options.UDPPacketCodec, } factory := proxyFactoryRegistry[reflect.TypeOf(configurer)] diff --git a/server/proxy/sudp_benchmark_test.go b/server/proxy/sudp_benchmark_test.go new file mode 100644 index 00000000..a2e337d9 --- /dev/null +++ b/server/proxy/sudp_benchmark_test.go @@ -0,0 +1,232 @@ +// Copyright 2026 The frp Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package proxy + +import ( + "bytes" + "fmt" + "io" + "net" + "testing" + + "github.com/fatedier/frp/pkg/msg" + "github.com/fatedier/frp/pkg/proto/wire" +) + +type sudpPathBenchmarkCase struct { + name string + packet *msg.UDPPacket +} + +var sudpPathBenchmarkBytesSink []byte + +func sudpPathBenchmarkCases(payloadSize int) []sudpPathBenchmarkCase { + content := bytes.Repeat([]byte{0x5a}, payloadSize) + return []sudpPathBenchmarkCase{ + { + name: "ipv4-remote", + packet: &msg.UDPPacket{ + Content: content, + RemoteAddr: &net.UDPAddr{ + IP: net.ParseIP("192.0.2.1"), Port: 12345, + }, + }, + }, + { + name: "ipv4-local-remote", + packet: &msg.UDPPacket{ + Content: content, + LocalAddr: &net.UDPAddr{ + IP: net.ParseIP("192.0.2.2"), Port: 23456, + }, + RemoteAddr: &net.UDPAddr{ + IP: net.ParseIP("192.0.2.1"), Port: 12345, + }, + }, + }, + { + name: "ipv6-remote", + packet: &msg.UDPPacket{ + Content: content, + RemoteAddr: &net.UDPAddr{ + IP: net.ParseIP("2001:db8::1"), Port: 12345, + }, + }, + }, + { + name: "ipv6-local-remote", + packet: &msg.UDPPacket{ + Content: content, + LocalAddr: &net.UDPAddr{ + IP: net.ParseIP("2001:db8::2"), Port: 23456, Zone: "bench0", + }, + RemoteAddr: &net.UDPAddr{ + IP: net.ParseIP("2001:db8::1"), Port: 12345, Zone: "bench1", + }, + }, + }, + } +} + +type sudpPathReadWriter struct { + reader bytes.Reader +} + +func (rw *sudpPathReadWriter) Read(p []byte) (int, error) { return rw.reader.Read(p) } +func (rw *sudpPathReadWriter) Write(p []byte) (int, error) { return len(p), nil } +func (rw *sudpPathReadWriter) Reset(p []byte) { rw.reader.Reset(p) } + +func sudpPathWireBytes(b testing.TB, packet *msg.UDPPacket, codec string) []byte { + b.Helper() + var buf bytes.Buffer + rw, err := msg.NewUDPPacketReadWriter(&buf, wire.ProtocolV2, codec) + if err != nil { + b.Fatal(err) + } + if err := rw.WriteMsg(packet); err != nil { + b.Fatal(err) + } + return append([]byte(nil), buf.Bytes()...) +} + +func sudpPathCopyFrame(dst, src []byte) []byte { + copy(dst, src) + return dst +} + +func BenchmarkSUDPInMemoryFrameCopy(b *testing.B) { + // This is an in-memory copy of an already encoded frame. It is a proxy for + // frame-size-dependent copy work, not a benchmark of libio.Join or sockets. + for _, payloadSize := range []int{64, 512, 1200, 1472} { + for _, tc := range sudpPathBenchmarkCases(payloadSize) { + for _, codec := range []struct { + name string + value string + }{ + {name: "json", value: ""}, + {name: "binary", value: wire.UDPPacketCodecBinary}, + } { + b.Run(fmt.Sprintf("payload-%d/%s/%s", payloadSize, tc.name, codec.name), func(b *testing.B) { + encoded := sudpPathWireBytes(b, tc.packet, codec.value) + dst := make([]byte, len(encoded)) + b.SetBytes(int64(len(encoded))) + for b.Loop() { + dst = sudpPathCopyFrame(dst, encoded) + } + if !bytes.Equal(dst, encoded) { + b.Fatal("copied frame does not match source") + } + sudpPathBenchmarkBytesSink = dst + }) + } + } + } +} + +func BenchmarkSUDPEndpointCodecPair(b *testing.B) { + // This measures an in-memory decode and re-encode with the same codec. It + // does not include the live SUDP server path, sockets, goroutines, or I/O. + for _, payloadSize := range []int{64, 512, 1200, 1472} { + for _, tc := range sudpPathBenchmarkCases(payloadSize) { + for _, codec := range []struct { + name string + value string + }{ + {name: "json", value: ""}, + {name: "binary", value: wire.UDPPacketCodecBinary}, + } { + b.Run(fmt.Sprintf("payload-%d/%s/%s", payloadSize, tc.name, codec.name), func(b *testing.B) { + encoded := sudpPathWireBytes(b, tc.packet, codec.value) + from := &sudpPathReadWriter{} + to := &bytes.Buffer{} + fromRW, err := msg.NewUDPPacketReadWriter(from, wire.ProtocolV2, codec.value) + if err != nil { + b.Fatal(err) + } + toRW, err := msg.NewUDPPacketReadWriter(to, wire.ProtocolV2, codec.value) + if err != nil { + b.Fatal(err) + } + b.SetBytes(int64(len(encoded))) + for b.Loop() { + from.Reset(encoded) + to.Reset() + m, err := fromRW.ReadMsg() + if err != nil { + b.Fatal(err) + } + if err := toRW.WriteMsg(m); err != nil { + b.Fatal(err) + } + } + if !bytes.Equal(to.Bytes(), encoded) { + b.Fatalf("re-encoded packet mismatch: got %d bytes, want %d", to.Len(), len(encoded)) + } + sudpPathBenchmarkBytesSink = to.Bytes() + }) + } + } + } +} + +func BenchmarkSUDPMixedCodecTranscodeModel(b *testing.B) { + // This exercises the real codec decode/re-encode pair used by the mixed + // bridge, excluding sockets, goroutines, crypto, compression, and framing I/O. + for _, payloadSize := range []int{64, 512, 1200, 1472} { + for _, tc := range sudpPathBenchmarkCases(payloadSize) { + for _, direction := range []struct { + name string + from string + to string + }{ + {name: "json-to-binary", from: "", to: wire.UDPPacketCodecBinary}, + {name: "binary-to-json", from: wire.UDPPacketCodecBinary, to: ""}, + } { + b.Run(fmt.Sprintf("payload-%d/%s/%s", payloadSize, tc.name, direction.name), func(b *testing.B) { + encoded := sudpPathWireBytes(b, tc.packet, direction.from) + expected := sudpPathWireBytes(b, tc.packet, direction.to) + from := &sudpPathReadWriter{} + to := &bytes.Buffer{} + fromRW, err := msg.NewUDPPacketReadWriter(from, wire.ProtocolV2, direction.from) + if err != nil { + b.Fatal(err) + } + toRW, err := msg.NewUDPPacketReadWriter(to, wire.ProtocolV2, direction.to) + if err != nil { + b.Fatal(err) + } + b.SetBytes(int64(len(encoded))) + for b.Loop() { + from.Reset(encoded) + to.Reset() + m, err := fromRW.ReadMsg() + if err != nil { + b.Fatal(err) + } + if err := toRW.WriteMsg(m); err != nil { + b.Fatal(err) + } + } + if !bytes.Equal(to.Bytes(), expected) { + b.Fatalf("transcoded packet mismatch: got %d bytes, want %d", to.Len(), len(expected)) + } + sudpPathBenchmarkBytesSink = to.Bytes() + }) + } + } + } +} + +var _ io.ReadWriter = (*sudpPathReadWriter)(nil) diff --git a/server/proxy/sudp_test.go b/server/proxy/sudp_test.go index e2986eed..b7ea9bc6 100644 --- a/server/proxy/sudp_test.go +++ b/server/proxy/sudp_test.go @@ -18,22 +18,27 @@ import ( "bufio" "bytes" "encoding/binary" + "io" + "net" "testing" + "time" "github.com/stretchr/testify/require" + v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/proto/wire" + "github.com/fatedier/frp/pkg/util/xlog" ) func TestSUDPBridgeTranscodesProxyV1ToVisitorV2(t *testing.T) { var in, out bytes.Buffer - writeSUDPBridgeMsg(t, &in, wire.ProtocolV1, &msg.UDPPacket{Content: []byte("proxy-to-visitor")}) + writeSUDPBridgeMsg(t, &in, wire.ProtocolV1, "", &msg.UDPPacket{Content: []byte("proxy-to-visitor")}) var count int64 err := bridgeSUDPProxyToVisitor( - msg.NewReadWriter(&in, wire.ProtocolV1), - msg.NewReadWriter(&out, wire.ProtocolV2), + newSUDPBridgeRW(t, &in, wire.ProtocolV1, ""), + newSUDPBridgeRW(t, &out, wire.ProtocolV2, ""), &count, nil, ) @@ -53,12 +58,12 @@ func TestSUDPBridgeTranscodesProxyV1ToVisitorV2(t *testing.T) { func TestSUDPBridgeTranscodesVisitorV2ToProxyV1(t *testing.T) { var in, out bytes.Buffer - writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, &msg.UDPPacket{Content: []byte("visitor-to-proxy")}) + writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, "", &msg.UDPPacket{Content: []byte("visitor-to-proxy")}) var count int64 err := bridgeSUDPVisitorToProxy( - msg.NewReadWriter(&in, wire.ProtocolV2), - msg.NewReadWriter(&out, wire.ProtocolV1), + newSUDPBridgeRW(t, &in, wire.ProtocolV2, ""), + newSUDPBridgeRW(t, &out, wire.ProtocolV1, ""), &count, nil, ) @@ -76,33 +81,67 @@ func TestSUDPBridgeTranscodesVisitorV2ToProxyV1(t *testing.T) { require.Equal(t, []byte("visitor-to-proxy"), got.Content) } -func TestSUDPBridgeForwardsProxyPing(t *testing.T) { +func TestSUDPBridgeTranscodesProxyV2BinaryToVisitorV2JSON(t *testing.T) { var in, out bytes.Buffer - writeSUDPBridgeMsg(t, &in, wire.ProtocolV1, &msg.Ping{}) + packet := newSUDPBridgeUDPPacket("proxy-binary-to-json") + writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, wire.UDPPacketCodecBinary, packet) var count int64 err := bridgeSUDPProxyToVisitor( - msg.NewReadWriter(&in, wire.ProtocolV1), - msg.NewReadWriter(&out, wire.ProtocolV2), + newSUDPBridgeRW(t, &in, wire.ProtocolV2, wire.UDPPacketCodecBinary), + newSUDPBridgeRW(t, &out, wire.ProtocolV2, ""), + &count, + nil, + ) + require.NoError(t, err) + require.Equal(t, int64(len(packet.Content)), count) + requireV2UDPPacketFrame(t, &out, msg.V2TypeUDPPacket, packet) +} + +func TestSUDPBridgeTranscodesVisitorV2JSONToProxyV2Binary(t *testing.T) { + var in, out bytes.Buffer + packet := newSUDPBridgeUDPPacket("visitor-json-to-binary") + writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, "", packet) + + var count int64 + err := bridgeSUDPVisitorToProxy( + newSUDPBridgeRW(t, &in, wire.ProtocolV2, ""), + newSUDPBridgeRW(t, &out, wire.ProtocolV2, wire.UDPPacketCodecBinary), + &count, + nil, + ) + require.NoError(t, err) + require.Equal(t, int64(len(packet.Content)), count) + requireV2UDPPacketFrame(t, &out, msg.V2TypeUDPPacketBinary, packet) +} + +func TestSUDPBridgeForwardsProxyPing(t *testing.T) { + var in, out bytes.Buffer + writeSUDPBridgeMsg(t, &in, wire.ProtocolV1, "", &msg.Ping{}) + + var count int64 + err := bridgeSUDPProxyToVisitor( + newSUDPBridgeRW(t, &in, wire.ProtocolV1, ""), + newSUDPBridgeRW(t, &out, wire.ProtocolV2, ""), &count, nil, ) require.NoError(t, err) require.Zero(t, count) - rawMsg, err := msg.NewReadWriter(&out, wire.ProtocolV2).ReadMsg() + rawMsg, err := newSUDPBridgeRW(t, &out, wire.ProtocolV2, "").ReadMsg() require.NoError(t, err) require.IsType(t, &msg.Ping{}, rawMsg) } func TestSUDPBridgeDropsVisitorPing(t *testing.T) { var in, out bytes.Buffer - writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, &msg.Ping{}) + writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, "", &msg.Ping{}) var count int64 err := bridgeSUDPVisitorToProxy( - msg.NewReadWriter(&in, wire.ProtocolV2), - msg.NewReadWriter(&out, wire.ProtocolV1), + newSUDPBridgeRW(t, &in, wire.ProtocolV2, ""), + newSUDPBridgeRW(t, &out, wire.ProtocolV1, ""), &count, nil, ) @@ -113,12 +152,12 @@ func TestSUDPBridgeDropsVisitorPing(t *testing.T) { func TestSUDPBridgeRejectsUnknownVisitorMessage(t *testing.T) { var in, out bytes.Buffer - writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, &msg.Pong{}) + writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, "", &msg.Pong{}) var count int64 err := bridgeSUDPVisitorToProxy( - msg.NewReadWriter(&in, wire.ProtocolV2), - msg.NewReadWriter(&out, wire.ProtocolV1), + newSUDPBridgeRW(t, &in, wire.ProtocolV2, ""), + newSUDPBridgeRW(t, &out, wire.ProtocolV1, ""), &count, nil, ) @@ -127,6 +166,22 @@ func TestSUDPBridgeRejectsUnknownVisitorMessage(t *testing.T) { require.Empty(t, out.Bytes()) } +func TestSUDPBridgeRejectsMismatchedPacketCodecOnStream(t *testing.T) { + var in, out bytes.Buffer + writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, "", newSUDPBridgeUDPPacket("json-on-binary-stream")) + + var count int64 + err := bridgeSUDPProxyToVisitor( + newSUDPBridgeRW(t, &in, wire.ProtocolV2, wire.UDPPacketCodecBinary), + newSUDPBridgeRW(t, &out, wire.ProtocolV2, ""), + &count, + nil, + ) + require.ErrorContains(t, err, "received JSON UDP packet after binary codec negotiation") + require.Zero(t, count) + require.Empty(t, out.Bytes()) +} + func TestSUDPBridgeDetectsMixedWireProtocol(t *testing.T) { require.False(t, isMixedWireProtocol("", wire.ProtocolV1)) require.False(t, isMixedWireProtocol(wire.ProtocolV2, wire.ProtocolV2)) @@ -134,8 +189,170 @@ func TestSUDPBridgeDetectsMixedWireProtocol(t *testing.T) { require.True(t, isMixedWireProtocol(wire.ProtocolV2, wire.ProtocolV1)) } -func writeSUDPBridgeMsg(t *testing.T, buf *bytes.Buffer, wireProtocol string, m msg.Message) { - t.Helper() - - require.NoError(t, msg.NewReadWriter(buf, wireProtocol).WriteMsg(m)) +func TestSUDPBridgeDetectsMixedPacketEncoding(t *testing.T) { + for _, tc := range []struct { + name string + leftWire string + leftCodec string + rightWire string + rightCodec string + mixed bool + }{ + {name: "legacy v1 aliases explicit v1", leftWire: "", rightWire: wire.ProtocolV1}, + {name: "v2 json matches v2 json", leftWire: wire.ProtocolV2, rightWire: wire.ProtocolV2}, + { + name: "v2 binary matches v2 binary", + leftWire: wire.ProtocolV2, + leftCodec: wire.UDPPacketCodecBinary, + rightWire: wire.ProtocolV2, + rightCodec: wire.UDPPacketCodecBinary, + }, + {name: "v1 json differs from v2 json", leftWire: wire.ProtocolV1, rightWire: wire.ProtocolV2, mixed: true}, + { + name: "v2 json differs from v2 binary", + leftWire: wire.ProtocolV2, + rightWire: wire.ProtocolV2, + rightCodec: wire.UDPPacketCodecBinary, + mixed: true, + }, + { + name: "v2 binary differs from v1 json", + leftWire: wire.ProtocolV2, + leftCodec: wire.UDPPacketCodecBinary, + rightWire: wire.ProtocolV1, + mixed: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + mixed, err := isMixedSUDPPacketEncoding(tc.leftWire, tc.leftCodec, tc.rightWire, tc.rightCodec) + require.NoError(t, err) + require.Equal(t, tc.mixed, mixed) + }) + } +} + +func TestSUDPBridgeRejectsInvalidEncodingMetadata(t *testing.T) { + for _, tc := range []struct { + name string + leftWire string + leftCodec string + rightWire string + rightCodec string + wantErr string + }{ + { + name: "left v1 binary", + leftWire: wire.ProtocolV1, + leftCodec: wire.UDPPacketCodecBinary, + rightWire: wire.ProtocolV1, + wantErr: "invalid left SUDP packet encoding", + }, + { + name: "right unknown v2 codec", + leftWire: wire.ProtocolV2, + rightWire: wire.ProtocolV2, + rightCodec: "snappy", + wantErr: "invalid right SUDP packet encoding", + }, + {name: "left unknown wire", leftWire: "v3", rightWire: wire.ProtocolV2, wantErr: "unsupported wire protocol"}, + } { + t.Run(tc.name, func(t *testing.T) { + mixed, err := isMixedSUDPPacketEncoding(tc.leftWire, tc.leftCodec, tc.rightWire, tc.rightCodec) + require.False(t, mixed) + require.ErrorContains(t, err, tc.wantErr) + }) + } +} + +func TestSUDPJoinUsesRawPathForSameEncodingState(t *testing.T) { + proxyClient, proxyServer := net.Pipe() + visitorClient, visitorServer := net.Pipe() + t.Cleanup(func() { + _ = proxyClient.Close() + _ = proxyServer.Close() + _ = visitorClient.Close() + _ = visitorServer.Close() + }) + deadline := time.Now().Add(3 * time.Second) + require.NoError(t, proxyClient.SetDeadline(deadline)) + require.NoError(t, proxyServer.SetDeadline(deadline)) + require.NoError(t, visitorClient.SetDeadline(deadline)) + require.NoError(t, visitorServer.SetDeadline(deadline)) + + pxy := &BaseProxy{ + configurer: &v1.SUDPProxyConfig{}, + wireProtocol: wire.ProtocolV2, + udpPacketCodec: wire.UDPPacketCodecBinary, + } + visitorConn := &metadataConn{Conn: visitorServer, wireProtocol: wire.ProtocolV2, udpPacketCodec: wire.UDPPacketCodecBinary} + joinDone := make(chan []error, 1) + go func() { + _, _, errs := pxy.joinUserConnection(proxyServer, visitorConn, string(v1.ProxyTypeSUDP), xlog.New()) + joinDone <- errs + }() + + raw := []byte{0, 16, 0, 0, 0, 4, 0xde, 0xad, 0xbe, 0xef} + _, err := proxyClient.Write(raw) + require.NoError(t, err) + got := make([]byte, len(raw)) + _, err = io.ReadFull(visitorClient, got) + require.NoError(t, err) + require.Equal(t, raw, got) + + _ = proxyClient.Close() + _ = visitorClient.Close() + <-joinDone +} + +func newSUDPBridgeRW(t *testing.T, buf *bytes.Buffer, wireProtocol, udpPacketCodec string) msg.ReadWriter { + t.Helper() + rw, err := msg.NewUDPPacketReadWriter(buf, wireProtocol, udpPacketCodec) + require.NoError(t, err) + return rw +} + +func writeSUDPBridgeMsg(t *testing.T, buf *bytes.Buffer, wireProtocol, udpPacketCodec string, m msg.Message) { + t.Helper() + require.NoError(t, newSUDPBridgeRW(t, buf, wireProtocol, udpPacketCodec).WriteMsg(m)) +} + +func newSUDPBridgeUDPPacket(content string) *msg.UDPPacket { + return &msg.UDPPacket{ + Content: []byte(content), + RemoteAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 12345}, + } +} + +func requireV2UDPPacketFrame(t *testing.T, buf *bytes.Buffer, wantType uint16, want *msg.UDPPacket) { + t.Helper() + frame, err := wire.NewConn(buf).ReadFrame() + require.NoError(t, err) + require.Equal(t, wire.FrameTypeMessage, frame.Type) + require.GreaterOrEqual(t, len(frame.Payload), 2) + require.Equal(t, wantType, binary.BigEndian.Uint16(frame.Payload[:2])) + var got *msg.UDPPacket + if wantType == msg.V2TypeUDPPacketBinary { + got, err = msg.DecodeUDPPacketBinary(frame.Payload[2:]) + } else { + var decoded msg.UDPPacket + err = msg.DecodeV2MessageFrameInto(frame, &decoded) + got = &decoded + } + require.NoError(t, err) + require.Equal(t, want.Content, got.Content) + require.Equal(t, want.RemoteAddr.String(), got.RemoteAddr.String()) +} + +type metadataConn struct { + net.Conn + wireProtocol string + udpPacketCodec string +} + +func (c *metadataConn) WireProtocol() string { + return c.wireProtocol +} + +func (c *metadataConn) UDPPacketCodec() string { + return c.udpPacketCodec } diff --git a/server/proxy/udp.go b/server/proxy/udp.go index 609d9c42..47bdfc8c 100644 --- a/server/proxy/udp.go +++ b/server/proxy/udp.go @@ -224,7 +224,13 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) { pxy.workConn = netpkg.WrapReadWriteCloserToConn(rwc, workConn) // Plain UDP payload follows the negotiated wire protocol for message framing. - payloadConn := msg.NewConn(pxy.workConn, msg.NewReadWriter(pxy.workConn, pxy.wireProtocol)) + payloadRW, err := msg.NewUDPPacketReadWriter(pxy.workConn, pxy.wireProtocol, pxy.udpPacketCodec) + if err != nil { + xl.Errorf("create UDP packet read writer: %v", err) + pxy.workConn.Close() + continue + } + payloadConn := msg.NewConn(pxy.workConn, payloadRW) ctx, cancel := context.WithCancel(context.Background()) go workConnReaderFn(payloadConn) go workConnSenderFn(payloadConn, ctx) diff --git a/server/service.go b/server/service.go index 0a174052..82883bb2 100644 --- a/server/service.go +++ b/server/service.go @@ -35,6 +35,7 @@ import ( "github.com/fatedier/frp/pkg/auth" v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/fatedier/frp/pkg/config/v1/validation" modelmetrics "github.com/fatedier/frp/pkg/metrics" "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/nathole" @@ -470,7 +471,7 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna } } if err == nil { - ctl, err = svr.RegisterControl(controlConn, m, internal, acceptedConn.wireProtocol) + ctl, err = svr.RegisterControl(controlConn, m, internal, acceptedConn.wireProtocol, acceptedConn.udpPacketCodec) } } @@ -509,7 +510,12 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna return } case *msg.NewWorkConn: - if err := svr.RegisterWorkConn(acceptedConn.conn, m); err != nil { + if err := svr.RegisterWorkConn( + acceptedConn.conn, + m, + acceptedConn.wireProtocol, + acceptedConn.clientHelloPresent, + ); err != nil { _ = acceptedConn.conn.WriteMsg(&msg.StartWorkConn{ Error: util.GenerateResponseErrorString("invalid NewWorkConn", err, lo.FromPtr(svr.cfg.DetailedErrorsToClient)), }) @@ -547,10 +553,12 @@ func (svr *Service) completeControlLogin(ctl *Control, writeSuccess func() error } type acceptedConnection struct { - conn *msg.Conn - wireProtocol string - cryptoContext *wire.CryptoContext - firstMsg msg.Message + conn *msg.Conn + wireProtocol string + clientHelloPresent bool + udpPacketCodec string + cryptoContext *wire.CryptoContext + firstMsg msg.Message } func (svr *Service) acceptConnection(ctx context.Context, conn net.Conn) (*acceptedConnection, error) { @@ -618,6 +626,7 @@ func (ac *acceptedConnection) readFirstV2Msg(conn net.Conn, wireConn *wire.Conn) return nil, fmt.Errorf("read v2 frame: %w", err) } if frame.Type == wire.FrameTypeClientHello { + ac.clientHelloPresent = true if err := ac.handleClientHello(conn, wireConn, frame); err != nil { return nil, err } @@ -666,6 +675,7 @@ func (ac *acceptedConnection) handleClientHello(conn net.Conn, wireConn *wire.Co return fmt.Errorf("write ServerHello: %w", err) } ac.cryptoContext = cryptoContext + ac.udpPacketCodec = serverHello.Selected.Message.UDPPacketCodec return nil } @@ -759,7 +769,20 @@ func (svr *Service) RegisterControl( loginMsg *msg.Login, internal bool, wireProtocol string, + udpPacketCodec string, ) (*Control, error) { + switch wireProtocol { + case wire.ProtocolV1: + if udpPacketCodec != "" { + return nil, fmt.Errorf("UDP packet codec %q requires wire protocol v2", udpPacketCodec) + } + case wire.ProtocolV2: + if udpPacketCodec != "" && udpPacketCodec != wire.UDPPacketCodecBinary { + return nil, fmt.Errorf("unsupported UDP packet codec selection: %s", udpPacketCodec) + } + default: + return nil, fmt.Errorf("unsupported wire protocol: %s", wireProtocol) + } // If client's RunID is empty, it's a new client, we just create a new controller. // Otherwise, we check if there is one controller has the same run id. If so, we release previous controller and start new one. var err error @@ -769,6 +792,9 @@ func (svr *Service) RegisterControl( return nil, err } } + if err := validation.ValidateRunID(loginMsg.RunID); err != nil { + return nil, fmt.Errorf("invalid run id: %w", err) + } ctx := netpkg.NewContextFromConn(ctlConn) xl := xlog.FromContextSafe(ctx) @@ -787,15 +813,16 @@ func (svr *Service) RegisterControl( } ctl, err := NewControl(ctx, &SessionContext{ - RC: svr.rc, - PxyManager: svr.pxyManager, - PluginManager: svr.pluginManager, - AuthVerifier: authVerifier, - EncryptionKey: svr.auth.EncryptionKey(), - Conn: ctlConn, - LoginMsg: loginMsg, - ServerCfg: svr.cfg, - WireProtocol: wireProtocol, + RC: svr.rc, + PxyManager: svr.pxyManager, + PluginManager: svr.pluginManager, + AuthVerifier: authVerifier, + EncryptionKey: svr.auth.EncryptionKey(), + Conn: ctlConn, + LoginMsg: loginMsg, + ServerCfg: svr.cfg, + WireProtocol: wireProtocol, + UDPPacketCodec: udpPacketCodec, }) if err != nil { xl.Warnf("create new controller error: %v", err) @@ -820,13 +847,24 @@ func (svr *Service) RegisterControl( } // RegisterWorkConn register a new work connection to control and proxies need it. -func (svr *Service) RegisterWorkConn(workConn *msg.Conn, newMsg *msg.NewWorkConn) error { +func (svr *Service) RegisterWorkConn( + workConn *msg.Conn, + newMsg *msg.NewWorkConn, + workWireProtocol string, + workClientHelloPresent bool, +) error { + if workClientHelloPresent { + return fmt.Errorf("ClientHello is not allowed on work connections") + } xl := netpkg.NewLogFromConn(workConn) ctl, exist := svr.ctlManager.GetByID(newMsg.RunID) if !exist { xl.Warnf("no client control found for run id [%s]", newMsg.RunID) return fmt.Errorf("no client control found for run id [%s]", newMsg.RunID) } + if workWireProtocol != ctl.sessionCtx.WireProtocol { + return fmt.Errorf("work connection wire protocol mismatch: got %s want %s", workWireProtocol, ctl.sessionCtx.WireProtocol) + } // server plugin hook content := &plugin.NewWorkConnContent{ @@ -851,14 +889,22 @@ func (svr *Service) RegisterWorkConn(workConn *msg.Conn, newMsg *msg.NewWorkConn } func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVisitorConn, wireProtocol string) error { - admit := func(visitorUser string) error { + admit := func(visitorUser, visitorWireProtocol, visitorUDPPacketCodec string) error { + if visitorWireProtocol == "" { + visitorWireProtocol = wireProtocol + } return svr.rc.VisitorManager.NewConn(newMsg.ProxyName, visitorConn, newMsg.Timestamp, newMsg.SignKey, - newMsg.UseEncryption, newMsg.UseCompression, visitorUser, wireProtocol) + newMsg.UseEncryption, newMsg.UseCompression, visitorUser, visitorWireProtocol, visitorUDPPacketCodec) } // TODO(deprecation): Compatible with old versions, can be without runID, user is empty. In later versions, it will be mandatory to include runID. // If runID is required, it is not compatible with versions prior to v0.50.0. if newMsg.RunID != "" { - admitted, err := svr.ctlManager.admitVisitorByRunID(newMsg.RunID, admit) + admitted, err := svr.ctlManager.admitVisitorByRunID(newMsg.RunID, func(visitorUser, controlWireProtocol, controlUDPPacketCodec string) error { + if wireProtocol != controlWireProtocol { + return fmt.Errorf("visitor connection wire protocol mismatch: got %s want %s", wireProtocol, controlWireProtocol) + } + return admit(visitorUser, controlWireProtocol, controlUDPPacketCodec) + }) if err != nil { return err } @@ -867,5 +913,5 @@ func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVis } return nil } - return admit("") + return admit("", wireProtocol, "") } diff --git a/server/service_test.go b/server/service_test.go index 0ebec604..d4fcbe77 100644 --- a/server/service_test.go +++ b/server/service_test.go @@ -17,9 +17,12 @@ package server import ( "context" "errors" + "fmt" + "math" "net" "net/http" "runtime" + "strings" "sync" "sync/atomic" "testing" @@ -30,6 +33,7 @@ import ( "github.com/fatedier/frp/pkg/auth" v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/fatedier/frp/pkg/config/v1/validation" "github.com/fatedier/frp/pkg/msg" plugin "github.com/fatedier/frp/pkg/plugin/server" "github.com/fatedier/frp/pkg/proto/wire" @@ -79,6 +83,70 @@ func TestWriteWithDeadlineTimesOutAndClearsDeadline(t *testing.T) { } } +func TestServiceAcceptConnectionTracksClientHelloPresence(t *testing.T) { + for _, tc := range []struct { + name string + clientHelloPresent bool + offeredCodecs []string + expectedCodec string + }{ + { + name: "absent Hello", + }, + { + name: "present Hello with JSON fallback", + clientHelloPresent: true, + }, + { + name: "present Hello with binary codec", + clientHelloPresent: true, + offeredCodecs: []string{wire.UDPPacketCodecBinary}, + expectedCodec: wire.UDPPacketCodecBinary, + }, + } { + t.Run(tc.name, func(t *testing.T) { + serverConn, clientConn := net.Pipe() + defer serverConn.Close() + defer clientConn.Close() + + clientErrCh := make(chan error, 1) + go func() { + if err := wire.WriteMagic(clientConn); err != nil { + clientErrCh <- err + return + } + wireConn := wire.NewConn(clientConn) + if tc.clientHelloPresent { + hello, err := wire.NewClientHello(wire.BootstrapInfo{}) + if err != nil { + clientErrCh <- err + return + } + hello.Capabilities.Message.UDPPacketCodecs = tc.offeredCodecs + if err := wireConn.WriteJSONFrame(wire.FrameTypeClientHello, hello); err != nil { + clientErrCh <- err + return + } + var serverHello wire.ServerHello + if err := wireConn.ReadJSONFrame(wire.FrameTypeServerHello, &serverHello); err != nil { + clientErrCh <- err + return + } + } + clientErrCh <- msg.NewV2ReadWriterWithConn(wireConn).WriteMsg(&msg.NewWorkConn{RunID: "shared-run"}) + }() + + acceptedConn, err := (&Service{}).acceptConnection(t.Context(), serverConn) + require.NoError(t, err) + require.NoError(t, <-clientErrCh) + require.Equal(t, tc.clientHelloPresent, acceptedConn.clientHelloPresent) + require.Equal(t, tc.expectedCodec, acceptedConn.udpPacketCodec) + require.IsType(t, &msg.NewWorkConn{}, acceptedConn.firstMsg) + require.NoError(t, acceptedConn.conn.Close()) + }) + } +} + func TestSharedPortHTTPListenerProtocols(t *testing.T) { listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) @@ -363,20 +431,24 @@ func TestServiceVisitorAdmissionSerializesReplacement(t *testing.T) { } t.Cleanup(resume) type admissionResult struct { - admitted bool - user string - err error + admitted bool + user string + wireProtocol string + udpPacketCodec string + err error } admissionDone := make(chan admissionResult, 1) go func() { - var admittedUser string - admitted, admitErr := svr.ctlManager.admitVisitorByRunID("shared-run", func(user string) error { - admittedUser = user + result := admissionResult{} + result.admitted, result.err = svr.ctlManager.admitVisitorByRunID("shared-run", func(user, wireProtocol, udpPacketCodec string) error { + result.user = user + result.wireProtocol = wireProtocol + result.udpPacketCodec = udpPacketCodec close(admissionEntered) <-resumeAdmission return nil }) - admissionDone <- admissionResult{admitted: admitted, user: admittedUser, err: admitErr} + admissionDone <- result }() waitForSignal(t, admissionEntered, "visitor admission callback") runMu := currentRunGateForTest(svr.ctlManager, "shared-run") @@ -412,6 +484,8 @@ func TestServiceVisitorAdmissionSerializesReplacement(t *testing.T) { require.NoError(t, admission.err) require.True(t, admission.admitted) require.Equal(t, "old-user", admission.user) + require.Equal(t, wire.ProtocolV1, admission.wireProtocol) + require.Empty(t, admission.udpPacketCodec) replacement := waitForResult(t, replacementDone, "replacement") require.NoError(t, replacement.err) ctlB := replacement.ctl @@ -428,7 +502,7 @@ func TestServiceWorkConnRoutingRequiresCurrentRunningControl(t *testing.T) { pendingConn := newCountingCloseConn() pendingMsgConn := msg.NewConn(pendingConn, msg.NewV1ReadWriter(pendingConn)) - err = registerWorkConnAsCaller(svr, pendingMsgConn, &msg.NewWorkConn{RunID: "shared-run"}) + err = registerWorkConnAsCaller(svr, pendingMsgConn, &msg.NewWorkConn{RunID: "shared-run"}, wire.ProtocolV1, false) require.Error(t, err) require.Equal(t, int64(1), pendingConn.closeCount.Load()) require.Len(t, ctl.workConnCh, 0) @@ -442,7 +516,7 @@ func TestServiceWorkConnRoutingRequiresCurrentRunningControl(t *testing.T) { runningConn := newCountingCloseConn() runningMsgConn := msg.NewConn(runningConn, msg.NewV1ReadWriter(runningConn)) - require.NoError(t, svr.RegisterWorkConn(runningMsgConn, &msg.NewWorkConn{RunID: "shared-run"})) + require.NoError(t, svr.RegisterWorkConn(runningMsgConn, &msg.NewWorkConn{RunID: "shared-run"}, wire.ProtocolV1, false)) require.Len(t, ctl.workConnCh, 1) require.NoError(t, ctl.Close()) @@ -450,6 +524,181 @@ func TestServiceWorkConnRoutingRequiresCurrentRunningControl(t *testing.T) { require.Equal(t, int64(1), runningConn.closeCount.Load()) } +func TestServiceWorkConnRoutingRejectsWireProtocolMismatch(t *testing.T) { + svr := newControlTestService(t) + ctl, controlConn, err := registerLifecycleTestControl(svr) + require.NoError(t, err) + require.NoError(t, svr.completeControlLogin(ctl, func() error { return nil })) + waitForSignal(t, controlConn.readStarted, "control reader to start") + + workConn := newCountingCloseConn() + workMsgConn := msg.NewConn(workConn, msg.NewV2ReadWriter(workConn)) + err = svr.RegisterWorkConn(workMsgConn, &msg.NewWorkConn{RunID: "shared-run"}, wire.ProtocolV2, false) + require.ErrorContains(t, err, "wire protocol mismatch") + require.Len(t, ctl.workConnCh, 0) + _ = workMsgConn.Close() + require.NoError(t, ctl.Close()) +} + +func TestServiceWorkConnRoutingClientHelloPolicy(t *testing.T) { + for _, tc := range []struct { + name string + controlUDPPacketCodec string + workClientHelloPresent bool + errorSubstring string + }{ + { + name: "JSON control allows work connection without Hello", + }, + { + name: "binary control allows work connection without Hello", + controlUDPPacketCodec: wire.UDPPacketCodecBinary, + }, + { + name: "JSON control rejects work connection with Hello", + workClientHelloPresent: true, + errorSubstring: "ClientHello is not allowed", + }, + { + name: "binary control rejects work connection with Hello", + controlUDPPacketCodec: wire.UDPPacketCodecBinary, + workClientHelloPresent: true, + errorSubstring: "ClientHello is not allowed", + }, + } { + t.Run(tc.name, func(t *testing.T) { + svr := newControlTestService(t) + controlConn := newDeadlineReadConn() + controlMsgConn := msg.NewConn(controlConn, msg.NewV2ReadWriter(controlConn)) + ctl, err := svr.RegisterControl(controlMsgConn, &msg.Login{ + RunID: "shared-run", + ClientID: "client", + ClientSpec: msg.ClientSpec{ + AlwaysAuthPass: true, + }, + }, true, wire.ProtocolV2, tc.controlUDPPacketCodec) + require.NoError(t, err) + require.NoError(t, svr.completeControlLogin(ctl, func() error { return nil })) + waitForSignal(t, controlConn.readStarted, "control reader to start") + + workConn := newCountingCloseConn() + workMsgConn := msg.NewConn(workConn, msg.NewV2ReadWriter(workConn)) + err = svr.RegisterWorkConn( + workMsgConn, + &msg.NewWorkConn{RunID: "shared-run"}, + wire.ProtocolV2, + tc.workClientHelloPresent, + ) + if tc.errorSubstring != "" { + require.ErrorContains(t, err, tc.errorSubstring) + require.Len(t, ctl.workConnCh, 0) + require.NoError(t, workMsgConn.Close()) + } else { + require.NoError(t, err) + require.Len(t, ctl.workConnCh, 1) + } + + require.NoError(t, ctl.Close()) + waitForControlDone(t, ctl) + require.Equal(t, int64(1), workConn.closeCount.Load()) + }) + } +} + +func TestServiceRegisterControlRejectsInvalidCodecSelection(t *testing.T) { + for _, tc := range []struct { + name string + wireProtocol string + udpPacketCodec string + errorSubstring string + }{ + { + name: "binary codec over v1", + wireProtocol: wire.ProtocolV1, + udpPacketCodec: wire.UDPPacketCodecBinary, + errorSubstring: "requires wire protocol v2", + }, + { + name: "unknown v2 codec", + wireProtocol: wire.ProtocolV2, + udpPacketCodec: "unknown", + errorSubstring: "unsupported UDP packet codec", + }, + { + name: "unknown wire protocol", + wireProtocol: "unknown", + errorSubstring: "unsupported wire protocol", + }, + } { + t.Run(tc.name, func(t *testing.T) { + svr := newControlTestService(t) + conn := newDeadlineReadConn() + msgConn := msg.NewConn(conn, msg.NewV1ReadWriter(conn)) + ctl, err := svr.RegisterControl(msgConn, &msg.Login{}, true, tc.wireProtocol, tc.udpPacketCodec) + require.Nil(t, ctl) + require.ErrorContains(t, err, tc.errorSubstring) + }) + } +} + +func TestServiceRegisterControlRejectsInvalidRunID(t *testing.T) { + for _, runID := range []string{ + "run\nforged", + strings.Repeat("a", validation.MaxRunIDLength+1), + } { + t.Run(fmt.Sprintf("run_id_%d", len(runID)), func(t *testing.T) { + svr := newControlTestService(t) + conn := newDeadlineReadConn() + msgConn := msg.NewConn(conn, msg.NewV1ReadWriter(conn)) + ctl, err := svr.RegisterControl(msgConn, &msg.Login{RunID: runID}, true, wire.ProtocolV1, "") + require.Nil(t, ctl) + require.ErrorContains(t, err, "invalid run id") + }) + } +} + +func TestServiceRegisterControlPoolCountBoundaries(t *testing.T) { + for _, tc := range []struct { + name string + poolCount int + wantErr bool + wantPool int + }{ + {name: "less than channel offset", poolCount: -11, wantErr: true}, + {name: "channel offset", poolCount: -10, wantErr: true}, + {name: "negative", poolCount: -1, wantErr: true}, + {name: "zero", poolCount: 0, wantPool: 0}, + {name: "capped by server maximum", poolCount: 10, wantPool: 5}, + {name: "maximum int capped", poolCount: math.MaxInt, wantPool: 5}, + } { + t.Run(tc.name, func(t *testing.T) { + svr := newControlTestService(t) + svr.cfg.Transport.MaxPoolCount = 5 + conn := newDeadlineReadConn() + msgConn := msg.NewConn(conn, msg.NewV1ReadWriter(conn)) + const timestamp = int64(1) + + ctl, err := svr.RegisterControl(msgConn, &msg.Login{ + RunID: "pool-count-run", + ClientID: "client", + Timestamp: timestamp, + PrivilegeKey: util.GetAuthKey("", timestamp), + PoolCount: tc.poolCount, + }, false, wire.ProtocolV1, "") + if tc.wantErr { + require.Nil(t, ctl) + require.ErrorContains(t, err, "unexpected error when creating new controller") + return + } + + require.NoError(t, err) + require.Equal(t, tc.wantPool, ctl.poolCount) + require.NoError(t, ctl.Close()) + waitForControlDone(t, ctl) + }) + } +} + func TestServiceWorkConnRoutingRejectsLostGeneration(t *testing.T) { for _, action := range []string{"replace", "close"} { t.Run(action, func(t *testing.T) { @@ -465,7 +714,7 @@ func TestServiceWorkConnRoutingRejectsLostGeneration(t *testing.T) { workMsgConn := msg.NewConn(workConn, msg.NewV1ReadWriter(workConn)) routeDone := make(chan error, 1) go func() { - routeDone <- registerWorkConnAsCaller(svr, workMsgConn, &msg.NewWorkConn{RunID: "shared-run"}) + routeDone <- registerWorkConnAsCaller(svr, workMsgConn, &msg.NewWorkConn{RunID: "shared-run"}, wire.ProtocolV1, false) }() waitForSignal(t, barrier.entered, "work connection plugin barrier") @@ -509,7 +758,7 @@ func TestServiceVisitorRoutingExcludesPendingUser(t *testing.T) { ClientSpec: msg.ClientSpec{ AlwaysAuthPass: true, }, - }, true, wire.ProtocolV1) + }, true, wire.ProtocolV1, "") require.NoError(t, err) timestamp := time.Now().Unix() @@ -538,6 +787,81 @@ func TestServiceVisitorRoutingExcludesPendingUser(t *testing.T) { waitForControlDone(t, ctl) } +func TestServiceVisitorRoutingCarriesControlPacketCodec(t *testing.T) { + svr := newControlTestService(t) + listener, err := svr.rc.VisitorManager.Listen("visitor", "secret", []string{"visitor-user"}) + require.NoError(t, err) + t.Cleanup(func() { _ = listener.Close() }) + + controlConn := newDeadlineReadConn() + controlMsgConn := msg.NewConn(controlConn, msg.NewV2ReadWriter(controlConn)) + ctl, err := svr.RegisterControl(controlMsgConn, &msg.Login{ + RunID: "visitor-binary-run", + User: "visitor-user", + ClientID: "visitor-client", + ClientSpec: msg.ClientSpec{ + AlwaysAuthPass: true, + }, + }, true, wire.ProtocolV2, wire.UDPPacketCodecBinary) + require.NoError(t, err) + + timestamp := time.Now().Unix() + visitorMsg := &msg.NewVisitorConn{ + RunID: "visitor-binary-run", + ProxyName: "visitor", + Timestamp: timestamp, + SignKey: util.GetAuthKey("secret", timestamp), + } + require.NoError(t, svr.completeControlLogin(ctl, func() error { return nil })) + waitForSignal(t, controlConn.readStarted, "binary visitor control reader to start") + + runningConn := newCountingCloseConn() + require.NoError(t, svr.RegisterVisitorConn(runningConn, visitorMsg, wire.ProtocolV2)) + accepted, err := listener.Accept() + require.NoError(t, err) + metadata, ok := accepted.(interface { + WireProtocol() string + UDPPacketCodec() string + }) + require.True(t, ok) + require.Equal(t, wire.ProtocolV2, metadata.WireProtocol()) + require.Equal(t, wire.UDPPacketCodecBinary, metadata.UDPPacketCodec()) + require.NoError(t, accepted.Close()) + require.Equal(t, int64(1), runningConn.closeCount.Load()) + + mismatchConn := newCountingCloseConn() + err = svr.RegisterVisitorConn(mismatchConn, visitorMsg, wire.ProtocolV1) + require.ErrorContains(t, err, "visitor connection wire protocol mismatch") + require.NoError(t, mismatchConn.Close()) + require.Equal(t, int64(1), mismatchConn.closeCount.Load()) + + require.NoError(t, ctl.Close()) + waitForControlDone(t, ctl) +} + +func TestServiceVisitorRoutingLegacyFallsBackToJSONPacketCodec(t *testing.T) { + svr := newControlTestService(t) + listener, err := svr.rc.VisitorManager.Listen("visitor", "secret", []string{""}) + require.NoError(t, err) + t.Cleanup(func() { _ = listener.Close() }) + + timestamp := time.Now().Unix() + visitorMsg := &msg.NewVisitorConn{ + ProxyName: "visitor", + Timestamp: timestamp, + SignKey: util.GetAuthKey("secret", timestamp), + } + visitorConn := newCountingCloseConn() + require.NoError(t, svr.RegisterVisitorConn(visitorConn, visitorMsg, wire.ProtocolV2)) + accepted, err := listener.Accept() + require.NoError(t, err) + metadata, ok := accepted.(interface{ UDPPacketCodec() string }) + require.True(t, ok) + require.Empty(t, metadata.UDPPacketCodec()) + require.NoError(t, accepted.Close()) + require.Equal(t, int64(1), visitorConn.closeCount.Load()) +} + func newControlTestService(t *testing.T) *Service { t.Helper() cfg := &v1.ServerConfig{} @@ -567,7 +891,7 @@ func registerLifecycleTestControl(svr *Service) (*Control, *deadlineReadConn, er ClientSpec: msg.ClientSpec{ AlwaysAuthPass: true, }, - }, true, wire.ProtocolV1) + }, true, wire.ProtocolV1, "") return ctl, conn, err } @@ -584,8 +908,14 @@ func waitForDifferentCurrentControl(t *testing.T, manager *ControlManager, runID return nil } -func registerWorkConnAsCaller(svr *Service, workConn *msg.Conn, newMsg *msg.NewWorkConn) error { - err := svr.RegisterWorkConn(workConn, newMsg) +func registerWorkConnAsCaller( + svr *Service, + workConn *msg.Conn, + newMsg *msg.NewWorkConn, + wireProtocol string, + clientHelloPresent bool, +) error { + err := svr.RegisterWorkConn(workConn, newMsg, wireProtocol, clientHelloPresent) if err != nil { _ = workConn.Close() } diff --git a/server/visitor/visitor.go b/server/visitor/visitor.go index 11a44367..7776aad8 100644 --- a/server/visitor/visitor.go +++ b/server/visitor/visitor.go @@ -65,8 +65,12 @@ func (vm *Manager) Listen(name string, sk string, allowUsers []string) (*netpkg. func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey string, useEncryption bool, useCompression bool, visitorUser string, - wireProtocol string, + wireProtocol string, udpPacketCodecs ...string, ) (err error) { + udpPacketCodec := "" + if len(udpPacketCodecs) > 0 { + udpPacketCodec = udpPacketCodecs[0] + } vm.mu.RLock() defer vm.mu.RUnlock() @@ -93,8 +97,9 @@ func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey } visitorConn := netpkg.WrapReadWriteCloserToConn(rwc, conn) err = l.l.PutConn(&wireProtocolConn{ - Conn: visitorConn, - wireProtocol: wireProtocol, + Conn: visitorConn, + wireProtocol: wireProtocol, + udpPacketCodec: udpPacketCodec, }) } else { err = fmt.Errorf("custom listener for [%s] doesn't exist", name) @@ -105,13 +110,18 @@ func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey type wireProtocolConn struct { net.Conn - wireProtocol string + wireProtocol string + udpPacketCodec string } func (c *wireProtocolConn) WireProtocol() string { return c.wireProtocol } +func (c *wireProtocolConn) UDPPacketCodec() string { + return c.udpPacketCodec +} + func (vm *Manager) CloseListener(name string) { vm.mu.Lock() defer vm.mu.Unlock() diff --git a/server/visitor/visitor_test.go b/server/visitor/visitor_test.go index 67b907fd..04c50ff0 100644 --- a/server/visitor/visitor_test.go +++ b/server/visitor/visitor_test.go @@ -25,7 +25,7 @@ import ( "github.com/fatedier/frp/pkg/util/util" ) -func TestManagerNewConnCarriesWireProtocol(t *testing.T) { +func TestManagerNewConnCarriesWireProtocolAndUDPPacketCodec(t *testing.T) { vm := NewManager() listener, err := vm.Listen("sudp", "secret", []string{"*"}) require.NoError(t, err) @@ -47,6 +47,7 @@ func TestManagerNewConnCarriesWireProtocol(t *testing.T) { false, "user", wire.ProtocolV2, + wire.UDPPacketCodecBinary, ) }() @@ -54,8 +55,12 @@ func TestManagerNewConnCarriesWireProtocol(t *testing.T) { require.NoError(t, err) defer acceptedConn.Close() - getter, ok := acceptedConn.(interface{ WireProtocol() string }) + metadata, ok := acceptedConn.(interface { + WireProtocol() string + UDPPacketCodec() string + }) require.True(t, ok) - require.Equal(t, wire.ProtocolV2, getter.WireProtocol()) + require.Equal(t, wire.ProtocolV2, metadata.WireProtocol()) + require.Equal(t, wire.UDPPacketCodecBinary, metadata.UDPPacketCodec()) require.NoError(t, <-errCh) } diff --git a/test/e2e/compatibility/compatibility_test.go b/test/e2e/compatibility/compatibility_test.go index 246bcac1..ad505510 100644 --- a/test/e2e/compatibility/compatibility_test.go +++ b/test/e2e/compatibility/compatibility_test.go @@ -192,6 +192,41 @@ transport.wireProtocol = "v2" }) }) +var _ = ginkgo.Describe("[Compatibility: BinaryUDPPacket]", func() { + f := framework.NewDefaultFramework() + + ginkgo.BeforeEach(func() { + supportsV2, knownVersion := baselineSupportsControlWireProtocolV2(compatCtx.BaselineVersion) + if !knownVersion || !supportsV2 { + ginkgo.Skip(fmt.Sprintf("baseline version %q does not have known wire protocol v2 support", compatCtx.BaselineVersion)) + } + }) + + ginkgo.It("current frps falls back to JSON for baseline frpc", func() { + portName := port.GenName("CompatBinaryUDPBaselineFRPC") + clientConf := udpClientConfig("udp", portName, `transport.wireProtocol = "v2"`) + f.RunProcessesWithBinaries( + compatCtx.CurrentFRPSPath, + compatCtx.BaselineFRPCPath, + consts.DefaultServerConfig, + []string{clientConf}, + ) + framework.NewRequestExpect(f).Protocol("udp").PortName(portName).Ensure() + }) + + ginkgo.It("current frpc falls back to JSON for baseline frps", func() { + portName := port.GenName("CompatBinaryUDPBaselineFRPS") + clientConf := udpClientConfig("udp", portName, `transport.wireProtocol = "v2"`) + f.RunProcessesWithBinaries( + compatCtx.BaselineFRPSPath, + compatCtx.CurrentFRPCPath, + consts.DefaultServerConfig, + []string{clientConf}, + ) + framework.NewRequestExpect(f).Protocol("udp").PortName(portName).Ensure() + }) +}) + func tcpClientConfig(proxyName string, remotePortName string, extra string) string { return fmt.Sprintf(` serverAddr = "127.0.0.1" @@ -208,6 +243,22 @@ remotePort = {{ .%s }} `, consts.PortServerName, extra, proxyName, framework.TCPEchoServerPort, remotePortName) } +func udpClientConfig(proxyName string, remotePortName string, extra string) string { + return fmt.Sprintf(` +serverAddr = "127.0.0.1" +serverPort = {{ .%s }} +loginFailExit = true +log.level = "trace" +%s + +[[proxies]] +name = "%s" +type = "udp" +localPort = {{ .%s }} +remotePort = {{ .%s }} +`, consts.PortServerName, extra, proxyName, framework.UDPEchoServerPort, remotePortName) +} + func expectProcessExit(p *process.Process, timeout time.Duration) { select { case <-p.Done(): diff --git a/test/e2e/v1/basic/wire.go b/test/e2e/v1/basic/wire.go index 051e6a6c..bf475287 100644 --- a/test/e2e/v1/basic/wire.go +++ b/test/e2e/v1/basic/wire.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "time" "github.com/onsi/ginkgo/v2" @@ -109,12 +110,12 @@ var _ = ginkgo.Describe("[Feature: WireProtocol]", func() { name: "default sudp visitor", }, { - name: "v2 sudp visitor", + name: "v2 binary raw sudp visitor", proxyWireConfig: `transport.wireProtocol = "v2"`, visitorWireConfig: `transport.wireProtocol = "v2"`, }, { - name: "mixed sudp proxy v1 visitor v2", + name: "v1 JSON proxy -> v2 Binary visitor transcode", proxyWireConfig: `transport.wireProtocol = "v1"`, visitorWireConfig: `transport.wireProtocol = "v2"`, extraProxyConfig: ` @@ -127,7 +128,7 @@ var _ = ginkgo.Describe("[Feature: WireProtocol]", func() { `, }, { - name: "mixed sudp proxy v2 visitor v1", + name: "v2 Binary proxy -> v1 JSON visitor transcode", proxyWireConfig: `transport.wireProtocol = "v2"`, visitorWireConfig: `transport.wireProtocol = "v1"`, }, @@ -204,6 +205,87 @@ var _ = ginkgo.Describe("[Feature: WireProtocol]", func() { }) }) +var _ = ginkgo.Describe("[Feature: BinaryUDPPacket]", func() { + f := framework.NewDefaultFramework() + + for _, tc := range []struct { + name string + protocol string + extraServer string + extraTransport string + }{ + {name: "tcp mux on", protocol: "tcp", extraTransport: "transport.tcpMux = true"}, + {name: "tcp mux off", protocol: "tcp", extraServer: "transport.tcpMux = false", extraTransport: "transport.tcpMux = false"}, + {name: "kcp", protocol: "kcp"}, + {name: "quic stream", protocol: "quic"}, + {name: "websocket", protocol: "websocket"}, + } { + ginkgo.It(tc.name, func() { + runClientServerTest(f, &generalTestConfigures{ + server: renderBindPortConfig(tc.protocol) + "\n" + tc.extraServer, + client: fmt.Sprintf(` + transport.wireProtocol = "v2" + transport.protocol = %q + %s + `, tc.protocol, tc.extraTransport), + }) + }) + } + + ginkgo.It("wss", func() { + wssPort := f.AllocPort() + runClientServerTest(f, &generalTestConfigures{ + clientPrefix: fmt.Sprintf(` + serverAddr = "127.0.0.1" + serverPort = %d + loginFailExit = false + transport.protocol = "wss" + transport.wireProtocol = "v2" + log.level = "trace" + `, wssPort), + client2: fmt.Sprintf(` + [[proxies]] + name = "wss2ws" + type = "tcp" + remotePort = %d + [proxies.plugin] + type = "https2http" + localAddr = "127.0.0.1:{{ .%s }}" + `, wssPort, consts.PortServerName), + testDelay: 10 * time.Second, + }) + }) + + for _, tc := range []struct { + name string + transport string + }{ + {name: "plain"}, + {name: "aes-cfb", transport: "transport.useEncryption = true"}, + {name: "snappy", transport: "transport.useCompression = true"}, + {name: "snappy and aes-cfb", transport: "transport.useEncryption = true\ntransport.useCompression = true"}, + {name: "limiter", transport: "transport.bandwidthLimit = \"1MB\""}, + } { + ginkgo.It(tc.name, func() { + serverConf := consts.DefaultServerConfig + udpPortName := port.GenName("BinaryUDPPacket") + clientConf := consts.DefaultClientConfig + fmt.Sprintf(` + transport.wireProtocol = "v2" + + [[proxies]] + name = "udp" + type = "udp" + localPort = {{ .%s }} + remotePort = {{ .%s }} + %s + `, framework.UDPEchoServerPort, udpPortName, tc.transport) + + f.RunProcesses(serverConf, []string{clientConf}) + framework.NewRequestExpect(f).Protocol("udp").PortName(udpPortName).Ensure() + }) + } +}) + type wireClientInfo struct { ClientID string `json:"clientID"` WireProtocol string `json:"wireProtocol"` diff --git a/web/frpc/auto-imports.d.ts b/web/frpc/auto-imports.d.ts index 1d89ee8c..9d240079 100644 --- a/web/frpc/auto-imports.d.ts +++ b/web/frpc/auto-imports.d.ts @@ -3,6 +3,7 @@ // @ts-nocheck // noinspection JSUnusedGlobalSymbols // Generated by unplugin-auto-import +// biome-ignore lint: disable export {} declare global { diff --git a/web/frpc/components.d.ts b/web/frpc/components.d.ts index c58d0bca..262d5831 100644 --- a/web/frpc/components.d.ts +++ b/web/frpc/components.d.ts @@ -1,10 +1,14 @@ /* eslint-disable */ -/* prettier-ignore */ // @ts-nocheck +// biome-ignore lint: disable +// oxlint-disable +// ------ // Generated by unplugin-vue-components // Read more: https://github.com/vuejs/core/pull/3399 + export {} +/* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { ConfigField: typeof import('./src/components/ConfigField.vue')['default'] @@ -38,10 +42,11 @@ declare module 'vue' { VisitorBaseSection: typeof import('./src/components/visitor-form/VisitorBaseSection.vue')['default'] VisitorConnectionSection: typeof import('./src/components/visitor-form/VisitorConnectionSection.vue')['default'] VisitorFormLayout: typeof import('./src/components/visitor-form/VisitorFormLayout.vue')['default'] + VisitorPluginSection: typeof import('./src/components/visitor-form/VisitorPluginSection.vue')['default'] VisitorTransportSection: typeof import('./src/components/visitor-form/VisitorTransportSection.vue')['default'] VisitorXtcpSection: typeof import('./src/components/visitor-form/VisitorXtcpSection.vue')['default'] } - export interface ComponentCustomProperties { + export interface GlobalDirectives { vLoading: typeof import('element-plus/es')['ElLoadingDirective'] } } diff --git a/web/frpc/package.json b/web/frpc/package.json index 6e20d893..17e8c594 100644 --- a/web/frpc/package.json +++ b/web/frpc/package.json @@ -9,13 +9,14 @@ "preview": "vite preview", "build-only": "vite build", "type-check": "vue-tsc --noEmit", - "lint": "eslint --fix" + "lint": "eslint . --fix", + "lint:check": "eslint ." }, "dependencies": { - "element-plus": "^2.13.0", + "element-plus": "^2.14.3", "pinia": "^3.0.4", - "vue": "^3.5.26", - "vue-router": "^4.6.4" + "vue": "^3.5.40", + "vue-router": "^5.2.0" }, "devDependencies": { "@types/node": "24", @@ -23,19 +24,19 @@ "@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-typescript": "^14.7.0", "@vue/tsconfig": "^0.8.1", - "@vueuse/core": "^14.1.0", - "eslint": "^9.39.0", - "eslint-plugin-vue": "^9.33.0", + "@vueuse/core": "^14.3.0", + "eslint": "^10.8.0", + "eslint-plugin-vue": "^10.10.0", "npm-run-all": "^4.1.5", - "prettier": "^3.7.4", - "sass": "^1.97.2", - "terser": "^5.44.1", + "prettier": "^3.9.6", + "sass": "^1.102.0", + "terser": "^5.49.0", "typescript": "^5.9.3", - "unplugin-auto-import": "^0.17.5", + "unplugin-auto-import": "^21.0.0", "unplugin-element-plus": "^0.11.2", - "unplugin-vue-components": "^0.26.0", + "unplugin-vue-components": "^32.1.0", "vite": "^7.3.0", "vite-svg-loader": "^5.1.0", - "vue-tsc": "^3.2.2" + "vue-tsc": "^3.3.8" } } diff --git a/web/frpc/src/components/ConfigField.vue b/web/frpc/src/components/ConfigField.vue index 27719847..67be4ba0 100644 --- a/web/frpc/src/components/ConfigField.vue +++ b/web/frpc/src/components/ConfigField.vue @@ -12,7 +12,7 @@ diff --git a/web/frpc/src/types/proxy-converters.ts b/web/frpc/src/types/proxy-converters.ts index 6587986e..eb4c4c17 100644 --- a/web/frpc/src/types/proxy-converters.ts +++ b/web/frpc/src/types/proxy-converters.ts @@ -190,6 +190,16 @@ export function formToStoreVisitor(form: VisitorFormData): VisitorDefinition { block.bindPort = form.bindPort } + if ( + form.pluginType === 'virtual_net' && + (form.type === 'stcp' || form.type === 'xtcp') + ) { + block.plugin = { + type: 'virtual_net', + destinationIP: form.pluginDestinationIP.trim(), + } + } + if (form.type === 'xtcp') { if (form.protocol && form.protocol !== 'quic') { block.protocol = form.protocol @@ -448,6 +458,15 @@ export function storeVisitorToForm( form.bindAddr = c.bindAddr || '127.0.0.1' form.bindPort = c.bindPort + // Visitor plugin (only supported by stcp/xtcp; sudp runtime never uses it) + if ( + c.plugin?.type === 'virtual_net' && + (form.type === 'stcp' || form.type === 'xtcp') + ) { + form.pluginType = 'virtual_net' + form.pluginDestinationIP = c.plugin.destinationIP || '' + } + // XTCP specific form.protocol = c.protocol || 'quic' form.keepTunnelOpen = c.keepTunnelOpen || false diff --git a/web/frpc/src/types/proxy-form.ts b/web/frpc/src/types/proxy-form.ts index 8f6bd614..793fe516 100644 --- a/web/frpc/src/types/proxy-form.ts +++ b/web/frpc/src/types/proxy-form.ts @@ -79,6 +79,10 @@ export interface VisitorFormData { bindAddr: string bindPort: number | undefined + // Visitor plugin + pluginType: '' | 'virtual_net' + pluginDestinationIP: string + // XTCP specific (XTCPVisitorConfig) protocol: string keepTunnelOpen: boolean @@ -156,6 +160,9 @@ export function createDefaultVisitorForm(): VisitorFormData { bindAddr: '127.0.0.1', bindPort: undefined, + pluginType: '', + pluginDestinationIP: '', + protocol: 'quic', keepTunnelOpen: false, maxRetriesAnHour: undefined, diff --git a/web/frpc/src/views/VisitorEdit.vue b/web/frpc/src/views/VisitorEdit.vue index 615e97e9..f095103d 100644 --- a/web/frpc/src/views/VisitorEdit.vue +++ b/web/frpc/src/views/VisitorEdit.vue @@ -107,6 +107,22 @@ const formRules: FormRules = { trigger: 'blur', }, ], + pluginDestinationIP: [ + { + validator: (_rule, value, callback) => { + if ( + form.value.pluginType === 'virtual_net' && + (form.value.type === 'stcp' || form.value.type === 'xtcp') && + !value?.trim() + ) { + callback(new Error('Destination IP is required for virtual_net')) + return + } + callback() + }, + trigger: 'blur', + }, + ], } const goBack = () => { diff --git a/web/frpc/test/config-field.test.ts b/web/frpc/test/config-field.test.ts new file mode 100644 index 00000000..64986274 --- /dev/null +++ b/web/frpc/test/config-field.test.ts @@ -0,0 +1,73 @@ +import { defineComponent } from 'vue' +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import ConfigField from '../src/components/ConfigField.vue' + +const InputStub = defineComponent({ + props: ['modelValue', 'disabled', 'placeholder', 'type'], + emits: ['update:modelValue'], + template: + '', +}) + +const FormItemStub = defineComponent({ + template: '
', +}) + +const stubs = { + 'el-form-item': FormItemStub, + 'el-input': InputStub, + 'el-switch': defineComponent({ + props: ['modelValue', 'disabled'], + emits: ['update:modelValue'], + template: '', + }), + KeyValueEditor: defineComponent({ template: '
' }), + StringListEditor: defineComponent({ template: '
' }), + PopoverMenu: defineComponent({ template: '
' }), + PopoverMenuItem: defineComponent({ template: '
' }), +} + +describe('ConfigField', () => { + it('clamps numeric input and emits the public model update', async () => { + const wrapper = mount(ConfigField, { + props: { label: 'Port', type: 'number', modelValue: 100, min: 1, max: 65535 }, + global: { stubs }, + }) + + const input = wrapper.find('input') + await input.setValue('70000') + + expect(wrapper.emitted('update:modelValue')).toContainEqual([65535]) + expect((input.element as HTMLInputElement).value).toBe('65535') + }) + + it('keeps a leading sign as an editable draft until it becomes numeric', async () => { + const wrapper = mount(ConfigField, { + props: { label: 'Port', type: 'number', modelValue: 100 }, + global: { stubs }, + }) + + const input = wrapper.find('input') + await wrapper.setProps({ modelValue: 200 }) + expect((input.element as HTMLInputElement).value).toBe('200') + + await input.setValue('-') + + expect(wrapper.emitted('update:modelValue')).toContainEqual([undefined]) + await wrapper.setProps({ modelValue: undefined }) + expect((input.element as HTMLInputElement).value).toBe('-') + }) + + it('renders an empty readonly field as a disabled placeholder', () => { + const wrapper = mount(ConfigField, { + props: { label: 'Secret', readonly: true, modelValue: '' }, + global: { stubs }, + }) + + const input = wrapper.find('input').element as HTMLInputElement + expect(wrapper.find('.config-field-label').text()).toBe('Secret') + expect(input.disabled).toBe(true) + expect(input.value).toBe('—') + }) +}) diff --git a/web/frpc/test/proxy-converters.test.ts b/web/frpc/test/proxy-converters.test.ts new file mode 100644 index 00000000..69e3410b --- /dev/null +++ b/web/frpc/test/proxy-converters.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import { + createDefaultProxyForm, + createDefaultVisitorForm, +} from '../src/types/proxy-form' +import { + formToStoreProxy, + formToStoreVisitor, + storeProxyToForm, +} from '../src/types/proxy-converters' + +describe('proxy converters', () => { + it('serializes only configured proxy fields into the typed store block', () => { + const form = createDefaultProxyForm() + Object.assign(form, { + name: 'web', + type: 'http', + enabled: false, + localIP: '10.0.0.8', + localPort: 8080, + useEncryption: true, + customDomains: ['example.com', ''], + locations: ['/api', ''], + metadatas: [{ key: 'team', value: 'edge' }], + }) + + expect(formToStoreProxy(form)).toEqual({ + name: 'web', + type: 'http', + http: { + enabled: false, + localIP: '10.0.0.8', + localPort: 8080, + transport: { useEncryption: true }, + metadatas: { team: 'edge' }, + customDomains: ['example.com'], + locations: ['/api'], + }, + }) + }) + + it('round-trips store values while applying form defaults', () => { + const form = storeProxyToForm({ + name: 'tcp-proxy', + type: 'tcp', + tcp: { + localPort: 9000, + customDomains: 'unused-for-tcp', + enabled: false, + metadatas: { owner: 'platform' }, + }, + }) + + expect(form).toMatchObject({ + name: 'tcp-proxy', + type: 'tcp', + enabled: false, + localIP: '127.0.0.1', + localPort: 9000, + metadatas: [{ key: 'owner', value: 'platform' }], + multiplexer: 'httpconnect', + }) + }) + + it.each(['stcp', 'xtcp'] as const)( + 'serializes virtual_net for %s visitors', + (type) => { + const form = createDefaultVisitorForm() + Object.assign(form, { + name: 'visitor', + type, + pluginType: 'virtual_net', + pluginDestinationIP: ' 192.0.2.10 ', + bindPort: 6000, + }) + + expect(formToStoreVisitor(form)[type]).toMatchObject({ + plugin: { type: 'virtual_net', destinationIP: '192.0.2.10' }, + bindPort: 6000, + }) + }, + ) + + it('does not serialize virtual_net for SUDP visitors', () => { + const form = createDefaultVisitorForm() + Object.assign(form, { + name: 'visitor', + type: 'sudp', + pluginType: 'virtual_net', + pluginDestinationIP: '192.0.2.10', + bindPort: 6000, + }) + + expect(formToStoreVisitor(form).sudp).toEqual({ bindPort: 6000 }) + }) +}) diff --git a/web/frpc/tsconfig.node.json b/web/frpc/tsconfig.node.json index 42872c59..a8583534 100644 --- a/web/frpc/tsconfig.node.json +++ b/web/frpc/tsconfig.node.json @@ -6,5 +6,5 @@ "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.mts"] } diff --git a/web/frpc/vite.config.mts b/web/frpc/vite.config.mts index 6a9205d0..209a91fd 100644 --- a/web/frpc/vite.config.mts +++ b/web/frpc/vite.config.mts @@ -28,15 +28,10 @@ export default defineConfig({ '@shared': fileURLToPath(new URL('../shared', import.meta.url)), }, dedupe: ['vue', 'element-plus', '@element-plus/icons-vue'], - modules: [ - fileURLToPath(new URL('../node_modules', import.meta.url)), - 'node_modules', - ], }, css: { preprocessorOptions: { scss: { - api: 'modern', additionalData: `@use "@shared/css/_index.scss" as *;`, }, }, diff --git a/web/frps/auto-imports.d.ts b/web/frps/auto-imports.d.ts index 1d89ee8c..9d240079 100644 --- a/web/frps/auto-imports.d.ts +++ b/web/frps/auto-imports.d.ts @@ -3,6 +3,7 @@ // @ts-nocheck // noinspection JSUnusedGlobalSymbols // Generated by unplugin-auto-import +// biome-ignore lint: disable export {} declare global { diff --git a/web/frps/components.d.ts b/web/frps/components.d.ts index cd53c58f..e177245b 100644 --- a/web/frps/components.d.ts +++ b/web/frps/components.d.ts @@ -1,10 +1,14 @@ /* eslint-disable */ -/* prettier-ignore */ // @ts-nocheck +// biome-ignore lint: disable +// oxlint-disable +// ------ // Generated by unplugin-vue-components // Read more: https://github.com/vuejs/core/pull/3399 + export {} +/* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { ClientCard: typeof import('./src/components/ClientCard.vue')['default'] @@ -25,7 +29,7 @@ declare module 'vue' { StatCard: typeof import('./src/components/StatCard.vue')['default'] Traffic: typeof import('./src/components/Traffic.vue')['default'] } - export interface ComponentCustomProperties { + export interface GlobalDirectives { vLoading: typeof import('element-plus/es')['ElLoadingDirective'] } } diff --git a/web/frps/package.json b/web/frps/package.json index c44c0dd4..8cc42d50 100644 --- a/web/frps/package.json +++ b/web/frps/package.json @@ -9,12 +9,13 @@ "preview": "vite preview", "build-only": "vite build", "type-check": "vue-tsc --noEmit", - "lint": "eslint --fix" + "lint": "eslint . --fix", + "lint:check": "eslint ." }, "dependencies": { - "element-plus": "^2.13.0", - "vue": "^3.5.26", - "vue-router": "^4.6.4" + "element-plus": "^2.14.3", + "vue": "^3.5.40", + "vue-router": "^5.2.0" }, "devDependencies": { "@types/node": "24", @@ -22,19 +23,19 @@ "@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-typescript": "^14.7.0", "@vue/tsconfig": "^0.8.1", - "@vueuse/core": "^14.1.0", - "eslint": "^9.39.0", - "eslint-plugin-vue": "^9.33.0", + "@vueuse/core": "^14.3.0", + "eslint": "^10.8.0", + "eslint-plugin-vue": "^10.10.0", "npm-run-all": "^4.1.5", - "prettier": "^3.7.4", - "sass": "^1.97.2", - "terser": "^5.44.1", + "prettier": "^3.9.6", + "sass": "^1.102.0", + "terser": "^5.49.0", "typescript": "^5.9.3", - "unplugin-auto-import": "^0.17.5", + "unplugin-auto-import": "^21.0.0", "unplugin-element-plus": "^0.11.2", - "unplugin-vue-components": "^0.26.0", + "unplugin-vue-components": "^32.1.0", "vite": "^7.3.0", "vite-svg-loader": "^5.1.0", - "vue-tsc": "^3.2.2" + "vue-tsc": "^3.3.8" } } diff --git a/web/frps/test/stat-card.test.ts b/web/frps/test/stat-card.test.ts new file mode 100644 index 00000000..e5f4cbb6 --- /dev/null +++ b/web/frps/test/stat-card.test.ts @@ -0,0 +1,48 @@ +import { defineComponent } from 'vue' +import { describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' + +const router = vi.hoisted(() => ({ push: vi.fn() })) +vi.mock('vue-router', () => ({ useRouter: () => router })) + +import StatCard from '../src/components/StatCard.vue' + +const stubs = { + 'el-card': defineComponent({ + template: '
', + emits: ['click'], + }), + 'el-icon': defineComponent({ template: '' }), +} + +describe('StatCard', () => { + it('shows its content and does not navigate without a destination', async () => { + router.push.mockClear() + const wrapper = mount(StatCard, { + props: { label: 'Clients', value: 3, subtitle: 'active' }, + global: { stubs }, + }) + + expect(wrapper.find('.stat-value').text()).toBe('3') + expect(wrapper.find('.stat-label').text()).toBe('Clients') + expect(wrapper.find('.stat-subtitle').text()).toBe('active') + expect(wrapper.find('.arrow-icon').exists()).toBe(false) + + await wrapper.find('.stat-card').trigger('click') + + expect(router.push).not.toHaveBeenCalled() + }) + + it('navigates only when a destination is provided', async () => { + router.push.mockClear() + const wrapper = mount(StatCard, { + props: { label: 'Proxies', value: '12', type: 'proxies', to: '/proxies' }, + global: { stubs }, + }) + + await wrapper.find('.stat-card').trigger('click') + + expect(router.push).toHaveBeenCalledWith('/proxies') + expect(wrapper.find('.arrow-icon').exists()).toBe(true) + }) +}) diff --git a/web/frps/tsconfig.node.json b/web/frps/tsconfig.node.json index 42872c59..a8583534 100644 --- a/web/frps/tsconfig.node.json +++ b/web/frps/tsconfig.node.json @@ -6,5 +6,5 @@ "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.mts"] } diff --git a/web/frps/vite.config.mts b/web/frps/vite.config.mts index 91f099f8..fda910d4 100644 --- a/web/frps/vite.config.mts +++ b/web/frps/vite.config.mts @@ -28,15 +28,10 @@ export default defineConfig({ '@shared': fileURLToPath(new URL('../shared', import.meta.url)), }, dedupe: ['vue', 'element-plus', '@element-plus/icons-vue'], - modules: [ - fileURLToPath(new URL('../node_modules', import.meta.url)), - 'node_modules', - ], }, css: { preprocessorOptions: { scss: { - api: 'modern', additionalData: `@use "@shared/css/_index.scss" as *;`, }, }, diff --git a/web/package-lock.json b/web/package-lock.json index 8ef6716a..d31d4b03 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,16 +9,22 @@ "shared", "frpc", "frps" - ] + ], + "devDependencies": { + "@vue/test-utils": "2.4.11", + "jsdom": "29.1.1", + "vitest": "4.1.10", + "vue": "^3.5.40" + } }, "frpc": { "name": "frpc-dashboard", "version": "0.0.1", "dependencies": { - "element-plus": "^2.13.0", + "element-plus": "^2.14.3", "pinia": "^3.0.4", - "vue": "^3.5.26", - "vue-router": "^4.6.4" + "vue": "^3.5.40", + "vue-router": "^5.2.0" }, "devDependencies": { "@types/node": "24", @@ -26,29 +32,29 @@ "@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-typescript": "^14.7.0", "@vue/tsconfig": "^0.8.1", - "@vueuse/core": "^14.1.0", - "eslint": "^9.39.0", - "eslint-plugin-vue": "^9.33.0", + "@vueuse/core": "^14.3.0", + "eslint": "^10.8.0", + "eslint-plugin-vue": "^10.10.0", "npm-run-all": "^4.1.5", - "prettier": "^3.7.4", - "sass": "^1.97.2", - "terser": "^5.44.1", + "prettier": "^3.9.6", + "sass": "^1.102.0", + "terser": "^5.49.0", "typescript": "^5.9.3", - "unplugin-auto-import": "^0.17.5", + "unplugin-auto-import": "^21.0.0", "unplugin-element-plus": "^0.11.2", - "unplugin-vue-components": "^0.26.0", + "unplugin-vue-components": "^32.1.0", "vite": "^7.3.0", "vite-svg-loader": "^5.1.0", - "vue-tsc": "^3.2.2" + "vue-tsc": "^3.3.8" } }, "frps": { "name": "frps-dashboard", "version": "0.0.1", "dependencies": { - "element-plus": "^2.13.0", - "vue": "^3.5.26", - "vue-router": "^4.6.4" + "element-plus": "^2.14.3", + "vue": "^3.5.40", + "vue-router": "^5.2.0" }, "devDependencies": { "@types/node": "24", @@ -56,57 +62,182 @@ "@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-typescript": "^14.7.0", "@vue/tsconfig": "^0.8.1", - "@vueuse/core": "^14.1.0", - "eslint": "^9.39.0", - "eslint-plugin-vue": "^9.33.0", + "@vueuse/core": "^14.3.0", + "eslint": "^10.8.0", + "eslint-plugin-vue": "^10.10.0", "npm-run-all": "^4.1.5", - "prettier": "^3.7.4", - "sass": "^1.97.2", - "terser": "^5.44.1", + "prettier": "^3.9.6", + "sass": "^1.102.0", + "terser": "^5.49.0", "typescript": "^5.9.3", - "unplugin-auto-import": "^0.17.5", + "unplugin-auto-import": "^21.0.0", "unplugin-element-plus": "^0.11.2", - "unplugin-vue-components": "^0.26.0", + "unplugin-vue-components": "^32.1.0", "vite": "^7.3.0", "vite-svg-loader": "^5.1.0", - "vue-tsc": "^3.2.2" + "vue-tsc": "^3.3.8" } }, - "node_modules/@antfu/utils": { - "version": "0.7.10", - "resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-0.7.10.tgz", - "integrity": "sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==", + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/generator": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", + "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/helper-string-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/parser": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz", + "integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==", + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/types": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", + "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.4" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -116,18 +247,167 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@bramus/specificity/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/@bramus/specificity/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@ctrl/tinycolor": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", @@ -592,167 +872,86 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", - "minimatch": "^3.1.5" + "minimatch": "^10.2.4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-array/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" + "@eslint/core": "^1.2.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^1.2.1", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } } }, "node_modules/@floating-ui/core": { @@ -832,11 +1031,28 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -847,7 +1063,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -858,7 +1073,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -885,7 +1099,6 @@ "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -938,6 +1151,7 @@ "hasInstallScript": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", @@ -980,6 +1194,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -1001,6 +1216,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -1022,6 +1238,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -1043,6 +1260,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -1064,6 +1282,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -1085,6 +1304,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -1106,6 +1326,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -1127,6 +1348,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -1148,6 +1370,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -1169,6 +1392,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -1190,6 +1414,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -1211,6 +1436,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -1232,6 +1458,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -1246,6 +1473,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "optional": true, + "peer": true, "engines": { "node": ">=12" }, @@ -1253,6 +1481,17 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@pkgr/core": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", @@ -1284,41 +1523,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", @@ -1669,11 +1873,49 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", "license": "MIT" }, "node_modules/@types/json-schema": { @@ -1712,7 +1954,6 @@ "version": "0.0.21", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", - "dev": true, "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { @@ -1975,6 +2216,129 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/language-core": { "version": "2.4.28", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", @@ -2004,14 +2368,41 @@ "vscode-uri": "^3.0.8" } }, - "node_modules/@vue/compiler-core": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", - "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", + "node_modules/@vue-macros/common": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.4.tgz", + "integrity": "sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@vue/shared": "3.5.30", + "@vue/compiler-sfc": "^3.5.22", + "ast-kit": "^2.1.2", + "local-pkg": "^1.1.2", + "magic-string-ast": "^1.0.2", + "unplugin-utils": "^0.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/vue-macros" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.2.25" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.40.tgz", + "integrity": "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.40", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" @@ -2030,40 +2421,40 @@ } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", - "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.40.tgz", + "integrity": "sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==", "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.30", - "@vue/shared": "3.5.30" + "@vue/compiler-core": "3.5.40", + "@vue/shared": "3.5.40" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", - "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.40.tgz", + "integrity": "sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@vue/compiler-core": "3.5.30", - "@vue/compiler-dom": "3.5.30", - "@vue/compiler-ssr": "3.5.30", - "@vue/shared": "3.5.30", + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.40", + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-ssr": "3.5.40", + "@vue/shared": "3.5.40", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", - "postcss": "^8.5.8", + "postcss": "^8.5.19", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", - "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.40.tgz", + "integrity": "sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.30", - "@vue/shared": "3.5.30" + "@vue/compiler-dom": "3.5.40", + "@vue/shared": "3.5.40" } }, "node_modules/@vue/devtools-api": { @@ -2141,26 +2532,27 @@ } }, "node_modules/@vue/language-core": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.6.tgz", - "integrity": "sha512-xYYYX3/aVup576tP/23sEUpgiEnujrENaoNRbaozC1/MA9I6EGFQRJb4xrt/MmUCAGlxTKL2RmT8JLTPqagCkg==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.8.tgz", + "integrity": "sha512-ieGT8jJdhhy0mGzStZhsg/qPw5bQZJg5yF+3+XU6saf4sM7yo9ZXy3h+nCwrm2+b4qS/SypkNdR2jAF3uei9tA==", "dev": true, "license": "MIT", "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", - "alien-signals": "^3.0.0", + "alien-signals": "^3.2.1", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", - "picomatch": "^4.0.2" + "picomatch": "^4.0.4" } }, "node_modules/@vue/language-core/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -2169,55 +2561,237 @@ } }, "node_modules/@vue/reactivity": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz", - "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.40.tgz", + "integrity": "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==", "license": "MIT", "dependencies": { - "@vue/shared": "3.5.30" + "@vue/shared": "3.5.40" } }, "node_modules/@vue/runtime-core": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz", - "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.40.tgz", + "integrity": "sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.30", - "@vue/shared": "3.5.30" + "@vue/reactivity": "3.5.40", + "@vue/shared": "3.5.40" } }, "node_modules/@vue/runtime-dom": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", - "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.40.tgz", + "integrity": "sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.30", - "@vue/runtime-core": "3.5.30", - "@vue/shared": "3.5.30", + "@vue/reactivity": "3.5.40", + "@vue/runtime-core": "3.5.40", + "@vue/shared": "3.5.40", "csstype": "^3.2.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz", - "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.40.tgz", + "integrity": "sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==", "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.5.30", - "@vue/shared": "3.5.30" - }, - "peerDependencies": { - "vue": "3.5.30" + "@vue/compiler-ssr": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/shared": "3.5.40" } }, "node_modules/@vue/shared": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz", - "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.40.tgz", + "integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==", "license": "MIT" }, + "node_modules/@vue/test-utils": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.11.tgz", + "integrity": "sha512-GDqaqZsA6m2E5vNzej0aYiIb6BX8xV9pNSbbbXKOfEYwg7ZNblVX8suyqmUBThq8VIrgAJNxn+z72hVtUeiWHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^3.0.0" + }, + "peerDependencies": { + "@vue/compiler-dom": "3.x", + "@vue/server-renderer": "3.x", + "vue": "3.x" + }, + "peerDependenciesMeta": { + "@vue/server-renderer": { + "optional": true + } + } + }, + "node_modules/@vue/test-utils/node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/test-utils/node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@vue/test-utils/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/test-utils/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@vue/test-utils/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@vue/test-utils/node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@vue/test-utils/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vue/test-utils/node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@vue/test-utils/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vue/test-utils/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vue/test-utils/node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@vue/test-utils/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@vue/tsconfig": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.8.1.tgz", @@ -2238,15 +2812,14 @@ } }, "node_modules/@vueuse/core": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz", - "integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==", - "dev": true, + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz", + "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", "license": "MIT", "dependencies": { "@types/web-bluetooth": "^0.0.21", - "@vueuse/metadata": "14.2.1", - "@vueuse/shared": "14.2.1" + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" }, "funding": { "url": "https://github.com/sponsors/antfu" @@ -2256,20 +2829,18 @@ } }, "node_modules/@vueuse/metadata": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz", - "integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==", - "dev": true, + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz", + "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/antfu" } }, "node_modules/@vueuse/shared": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz", - "integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==", - "dev": true, + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz", + "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/antfu" @@ -2282,7 +2853,6 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -2319,12 +2889,25 @@ } }, "node_modules/alien-signals": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz", - "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", "dev": true, "license": "MIT" }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2341,27 +2924,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -2401,6 +2963,49 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-kit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", + "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "pathe": "^2.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-walker-scope": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.9.0.tgz", + "integrity": "sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@babel/types": "^7.29.0", + "ast-kit": "^2.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -2443,17 +3048,14 @@ "node": "18 || 20 || >=22" } }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "dependencies": { + "require-from-string": "^2.0.2" } }, "node_modules/birpc": { @@ -2473,15 +3075,16 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { @@ -2533,22 +3136,6 @@ } } }, - "node_modules/c12/node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/c12/node_modules/confbox": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", @@ -2586,20 +3173,6 @@ "destr": "^2.0.3" } }, - "node_modules/c12/node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -2650,44 +3223,26 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">=18" } }, "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "license": "MIT", "dependencies": { - "readdirp": "^4.0.1" + "readdirp": "^5.0.0" }, "engines": { - "node": ">= 14.16.0" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -2741,9 +3296,19 @@ "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "dev": true, "license": "MIT" }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, "node_modules/consola": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", @@ -2754,6 +3319,13 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/copy-anything": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", @@ -2883,6 +3455,20 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -2961,6 +3547,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3024,6 +3617,7 @@ "dev": true, "license": "Apache-2.0", "optional": true, + "peer": true, "engines": { "node": ">=8" } @@ -3115,73 +3709,46 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/element-plus": { - "version": "2.13.5", - "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.5.tgz", - "integrity": "sha512-dmY24fhSREfZN/PuUt0YZigMso7wWzl+B5o+YKNN15kQIn/0hzamsPU+ebj9SES0IbUqsLX1wkrzYmzU8VrVOQ==", + "version": "2.14.3", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.3.tgz", + "integrity": "sha512-pJcvxcpZjYruNzuJhAeVwnbYjfNgzBKnWHwSVEhwzM2/kcLI3brzmtIBxtPqd4hQWJfD1PRnjoc1WipLw2eBGg==", "license": "MIT", "dependencies": { "@ctrl/tinycolor": "^4.2.0", "@element-plus/icons-vue": "^2.3.2", - "@floating-ui/dom": "^1.0.1", - "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", - "@types/lodash": "^4.17.20", + "@floating-ui/dom": "^1.7.6", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", "@types/lodash-es": "^4.17.12", - "@vueuse/core": "12.0.0", + "@vueuse/core": "14.3.0", "async-validator": "^4.2.5", - "dayjs": "^1.11.19", - "lodash": "^4.17.23", - "lodash-es": "^4.17.23", + "dayjs": "^1.11.20", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", "lodash-unified": "^1.0.3", "memoize-one": "^6.0.0", - "normalize-wheel-es": "^1.2.0" + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.3.5" }, "peerDependencies": { - "vue": "^3.3.0" + "vue": "^3.3.7" } }, - "node_modules/element-plus/node_modules/@types/web-bluetooth": { - "version": "0.0.20", - "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", - "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, "license": "MIT" }, - "node_modules/element-plus/node_modules/@vueuse/core": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.0.0.tgz", - "integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==", - "license": "MIT", - "dependencies": { - "@types/web-bluetooth": "^0.0.20", - "@vueuse/metadata": "12.0.0", - "@vueuse/shared": "12.0.0", - "vue": "^3.5.13" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/element-plus/node_modules/@vueuse/metadata": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.0.0.tgz", - "integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/element-plus/node_modules/@vueuse/shared": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.0.0.tgz", - "integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==", - "license": "MIT", - "dependencies": { - "vue": "^3.5.13" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -3359,7 +3926,7 @@ "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" @@ -3410,33 +3977,33 @@ } }, "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", - "@eslint/plugin-kit": "^0.4.1", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", - "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -3446,8 +4013,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -3455,7 +4021,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -3517,117 +4083,52 @@ } }, "node_modules/eslint-plugin-vue": { - "version": "9.33.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.33.0.tgz", - "integrity": "sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==", + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.10.0.tgz", + "integrity": "sha512-dL9x9rBHqqNcByWiLOHK6L0SB97V82/NC0cZRn9cXPjM7pCuWlpQQP9bFH4vjBv80ej1ZpzAkuD8zWH1o9bZbA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "globals": "^13.24.0", + "@eslint-community/eslint-utils": "^4.9.1", "natural-compare": "^1.4.0", "nth-check": "^2.1.1", - "postcss-selector-parser": "^6.0.15", - "semver": "^7.6.3", - "vue-eslint-parser": "^9.4.3", - "xml-name-validator": "^4.0.0" - }, - "engines": { - "node": "^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0" - } - }, - "node_modules/eslint-plugin-vue/node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-plugin-vue/node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-plugin-vue/node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint-plugin-vue/node_modules/vue-eslint-parser": { - "version": "9.4.3", - "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz", - "integrity": "sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4", - "eslint-scope": "^7.1.1", - "eslint-visitor-keys": "^3.3.0", - "espree": "^9.3.1", - "esquery": "^1.4.0", - "lodash": "^4.17.21", - "semver": "^7.3.6" - }, - "engines": { - "node": "^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - }, - "peerDependencies": { - "eslint": ">=6.0.0" - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" + "postcss-selector-parser": "^7.1.4", + "semver": "^7.8.5", + "xml-name-validator": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, + "peerDependencies": { + "@stylistic/eslint-plugin": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", + "@typescript-eslint/parser": "^7.0.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "vue-eslint-parser": "^10.3.0" + }, + "peerDependenciesMeta": { + "@stylistic/eslint-plugin": { + "optional": true + }, + "@typescript-eslint/parser": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, "funding": { "url": "https://opencollective.com/eslint" } @@ -3645,76 +4146,45 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -3772,11 +4242,20 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", - "dev": true, "license": "MIT" }, "node_modules/fast-deep-equal": { @@ -3927,6 +4406,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/frp-shared": { "resolved": "shared", "link": true @@ -4093,19 +4589,6 @@ "node": ">=10.13.0" } }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -4156,16 +4639,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", @@ -4250,6 +4723,19 @@ "dev": true, "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4267,23 +4753,6 @@ "dev": true, "license": "MIT" }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -4294,6 +4763,13 @@ "node": ">=0.8.19" } }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4370,19 +4846,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/is-boolean-object": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", @@ -4490,6 +4953,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -4576,6 +5049,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -4747,6 +5227,22 @@ "dev": true, "license": "ISC" }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -4757,6 +5253,13 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-cookie": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -4764,26 +5267,103 @@ "dev": true, "license": "MIT" }, - "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", "dev": true, "funding": [ { "type": "github", - "url": "https://github.com/sponsors/puzrin" + "url": "https://github.com/sponsors/csstools" }, { - "type": "github", - "url": "https://github.com/sponsors/nodeca" + "type": "opencollective", + "url": "https://opencollective.com/csstools" } ], - "dependencies": { - "argparse": "^2.0.1" + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/jsdom/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", "bin": { - "js-yaml": "bin/js-yaml.js" + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" } }, "node_modules/json-buffer": { @@ -4814,6 +5394,18 @@ "dev": true, "license": "MIT" }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4872,14 +5464,14 @@ } }, "node_modules/local-pkg": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", - "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", - "dev": true, + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz", + "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==", "license": "MIT", "dependencies": { - "mlly": "^1.7.3", - "pkg-types": "^1.2.1" + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" }, "engines": { "node": ">=14" @@ -4888,6 +5480,23 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/local-pkg/node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/local-pkg/node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4925,12 +5534,15 @@ "lodash-es": "*" } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "dev": true, - "license": "MIT" + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/magic-string": { "version": "0.30.21", @@ -4941,6 +5553,21 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magic-string-ast": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz", + "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==", + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.19" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4998,13 +5625,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -5013,6 +5640,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -5020,10 +5657,9 @@ "license": "MIT" }, "node_modules/mlly": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.1.tgz", - "integrity": "sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==", - "dev": true, + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", "license": "MIT", "dependencies": { "acorn": "^8.16.0", @@ -5043,13 +5679,12 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -5084,7 +5719,8 @@ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/node-fetch-native": { "version": "1.6.7", @@ -5116,22 +5752,18 @@ "semver": "bin/semver" } }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/normalize-wheel-es": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", "license": "BSD-3-Clause" }, + "node_modules/nostics": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/nostics/-/nostics-1.2.0.tgz", + "integrity": "sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==", + "license": "MIT" + }, "node_modules/npm-run-all": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", @@ -5179,9 +5811,9 @@ "license": "MIT" }, "node_modules/npm-run-all/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -5422,6 +6054,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -5497,18 +6143,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parent-module": { + "node_modules/package-json-from-dist": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } + "license": "BlueOak-1.0.0" }, "node_modules/parse-json": { "version": "4.0.0", @@ -5524,6 +6164,32 @@ "node": ">=4" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -5575,7 +6241,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, "license": "MIT" }, "node_modules/perfect-debounce": { @@ -5650,7 +6315,6 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "dev": true, "license": "MIT", "dependencies": { "confbox": "^0.1.8", @@ -5669,9 +6333,9 @@ } }, "node_modules/postcss": { - "version": "8.5.12", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", - "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "funding": [ { "type": "opencollective", @@ -5686,8 +6350,9 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5696,9 +6361,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", "dev": true, "license": "MIT", "dependencies": { @@ -5720,9 +6385,9 @@ } }, "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", "bin": { @@ -5748,6 +6413,13 @@ "node": ">=6.0.0" } }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5762,7 +6434,6 @@ "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", - "dev": true, "funding": [ { "type": "individual", @@ -5823,13 +6494,12 @@ } }, "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", "license": "MIT", "engines": { - "node": ">= 14.18.0" + "node": ">= 20.19.0" }, "funding": { "type": "individual", @@ -5880,6 +6550,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -5901,16 +6581,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -5948,7 +6618,7 @@ "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -6069,13 +6739,13 @@ } }, "node_modules/sass": { - "version": "1.98.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.98.0.tgz", - "integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==", + "version": "1.102.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.102.0.tgz", + "integrity": "sha512-NSOyTnaQF7rTAEOtI2fwb386vL+akyiQLBZu8Na7hXCb+umJy0GAqlcMIaqACZ6Z1VgTBS4K9PG6B3IdjHGJsw==", "dev": true, "license": "MIT", "dependencies": { - "chokidar": "^4.0.0", + "chokidar": "^5.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, @@ -6083,7 +6753,7 @@ "sass": "sass.js" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.19.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" @@ -6099,17 +6769,29 @@ "node": ">=11.0.0" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scule": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", - "dev": true, "license": "MIT" }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -6279,6 +6961,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -6354,6 +7056,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -6368,6 +7084,70 @@ "node": ">= 0.4" } }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.padend": { "version": "3.1.6", "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.6.tgz", @@ -6446,6 +7226,46 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -6456,23 +7276,10 @@ "node": ">=4" } }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/strip-literal": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", - "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", "dev": true, "license": "MIT", "dependencies": { @@ -6494,19 +7301,6 @@ "node": ">=16" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -6556,6 +7350,13 @@ "node": ">= 10" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/synckit": { "version": "0.11.12", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", @@ -6573,9 +7374,9 @@ } }, "node_modules/terser": { - "version": "5.46.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", - "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", + "version": "5.49.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.49.0.tgz", + "integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -6591,6 +7392,13 @@ "node": ">=10" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", @@ -6602,14 +7410,13 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -6622,7 +7429,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -6640,7 +7446,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, "engines": { "node": ">=12" }, @@ -6648,6 +7453,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6661,6 +7496,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -6687,19 +7548,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -6820,7 +7668,6 @@ "version": "1.6.3", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", - "dev": true, "license": "MIT" }, "node_modules/unbox-primitive": { @@ -6865,32 +7712,14 @@ "@types/estree": "^1.0.0" } }, - "node_modules/unctx/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/unctx/node_modules/unplugin": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", - "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "acorn": "^8.15.0", - "picomatch": "^4.0.3", - "webpack-virtual-modules": "^0.6.2" - }, "engines": { - "node": ">=18.12.0" + "node": ">=20.18.1" } }, "node_modules/undici-types": { @@ -6901,26 +7730,29 @@ "license": "MIT" }, "node_modules/unimport": { - "version": "3.14.6", - "resolved": "https://registry.npmjs.org/unimport/-/unimport-3.14.6.tgz", - "integrity": "sha512-CYvbDaTT04Rh8bmD8jz3WPmHYZRG/NnvYVzwD6V1YAlvvKROlAeNDUBhkBGzNav2RKaeuXvlWYaa1V4Lfi/O0g==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/unimport/-/unimport-5.7.0.tgz", + "integrity": "sha512-njnL6sp8lEA8QQbZrt+52p/g4X0rw3bnGGmUcJnt1jeG8+iiqO779aGz0PirCtydAIVcuTBRlJ52F0u46z309Q==", "dev": true, "license": "MIT", "dependencies": { - "@rollup/pluginutils": "^5.1.4", - "acorn": "^8.14.0", + "acorn": "^8.16.0", "escape-string-regexp": "^5.0.0", "estree-walker": "^3.0.3", - "fast-glob": "^3.3.3", - "local-pkg": "^1.0.0", - "magic-string": "^0.30.17", - "mlly": "^1.7.4", - "pathe": "^2.0.1", - "picomatch": "^4.0.2", - "pkg-types": "^1.3.0", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.0", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "pkg-types": "^2.3.0", "scule": "^1.3.0", - "strip-literal": "^2.1.1", - "unplugin": "^1.16.1" + "strip-literal": "^3.1.0", + "tinyglobby": "^0.2.15", + "unplugin": "^2.3.11", + "unplugin-utils": "^0.3.1" + }, + "engines": { + "node": ">=18.12.0" } }, "node_modules/unimport/node_modules/confbox": { @@ -6953,41 +7785,12 @@ "@types/estree": "^1.0.0" } }, - "node_modules/unimport/node_modules/local-pkg": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", - "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", - "dev": true, - "license": "MIT", - "dependencies": { - "mlly": "^1.7.4", - "pkg-types": "^2.3.0", - "quansync": "^0.2.11" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/unimport/node_modules/local-pkg/node_modules/pkg-types": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", - "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", - "dev": true, - "license": "MIT", - "dependencies": { - "confbox": "^0.2.2", - "exsolve": "^1.0.7", - "pathe": "^2.0.3" - } - }, "node_modules/unimport/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -6995,44 +7798,56 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/unplugin": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.16.1.tgz", - "integrity": "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==", + "node_modules/unimport/node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", "dev": true, "license": "MIT", "dependencies": { - "acorn": "^8.14.0", + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" }, "engines": { - "node": ">=14.0.0" + "node": ">=18.12.0" } }, "node_modules/unplugin-auto-import": { - "version": "0.17.8", - "resolved": "https://registry.npmjs.org/unplugin-auto-import/-/unplugin-auto-import-0.17.8.tgz", - "integrity": "sha512-CHryj6HzJ+n4ASjzwHruD8arhbdl+UXvhuAIlHDs15Y/IMecG3wrf7FVg4pVH/DIysbq/n0phIjNHAjl7TG7Iw==", + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/unplugin-auto-import/-/unplugin-auto-import-21.0.0.tgz", + "integrity": "sha512-vWuC8SwqJmxZFYwPojhOhOXDb5xFhNNcEVb9K/RFkyk/3VnfaOjzitWN7v+8DEKpMjSsY2AEGXNgt6I0yQrhRQ==", "dev": true, "license": "MIT", "dependencies": { - "@antfu/utils": "^0.7.10", - "@rollup/pluginutils": "^5.1.0", - "fast-glob": "^3.3.2", - "local-pkg": "^0.5.0", - "magic-string": "^0.30.10", - "minimatch": "^9.0.4", - "unimport": "^3.7.2", - "unplugin": "^1.11.0" + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "picomatch": "^4.0.3", + "unimport": "^5.6.0", + "unplugin": "^2.3.11", + "unplugin-utils": "^0.3.1" }, "engines": { - "node": ">=14" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/sponsors/antfu" }, "peerDependencies": { - "@nuxt/kit": "^3.2.2", + "@nuxt/kit": "^4.0.0", "@vueuse/core": "*" }, "peerDependenciesMeta": { @@ -7044,37 +7859,17 @@ } } }, - "node_modules/unplugin-auto-import/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/unplugin-auto-import/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "node_modules/unplugin-auto-import/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/unplugin-auto-import/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/unplugin-element-plus": { @@ -7156,18 +7951,6 @@ "node": ">= 4" } }, - "node_modules/unplugin-element-plus/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/unplugin-element-plus/node_modules/pkg-types": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", @@ -7180,155 +7963,146 @@ "pathe": "^2.0.3" } }, - "node_modules/unplugin-element-plus/node_modules/unplugin": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", - "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", - "dev": true, + "node_modules/unplugin-utils": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.2.tgz", + "integrity": "sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==", "license": "MIT", "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "acorn": "^8.15.0", - "picomatch": "^4.0.3", - "webpack-virtual-modules": "^0.6.2" + "pathe": "^2.0.3", + "picomatch": "^4.0.4" }, "engines": { - "node": ">=18.12.0" + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/unplugin-utils/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/unplugin-vue-components": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-0.26.0.tgz", - "integrity": "sha512-s7IdPDlnOvPamjunVxw8kNgKNK8A5KM1YpK5j/p97jEKTjlPNrA0nZBiSfAKKlK1gWZuyWXlKL5dk3EDw874LQ==", + "version": "32.1.0", + "resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-32.1.0.tgz", + "integrity": "sha512-YiUkSxuRjab18XFOrX5VsIxXzccrfmHVGsGeJgSgklb829DQmCy9E4vvDUE4tuvZZdxyFJZX0Oc4TPnnxiiMyg==", "dev": true, "license": "MIT", "dependencies": { - "@antfu/utils": "^0.7.6", - "@rollup/pluginutils": "^5.0.4", - "chokidar": "^3.5.3", - "debug": "^4.3.4", - "fast-glob": "^3.3.1", - "local-pkg": "^0.4.3", - "magic-string": "^0.30.3", - "minimatch": "^9.0.3", - "resolve": "^1.22.4", - "unplugin": "^1.4.0" + "chokidar": "^5.0.0", + "local-pkg": "^1.2.0", + "magic-string": "^0.30.21", + "mlly": "^1.8.2", + "obug": "^2.1.1", + "picomatch": "^4.0.4", + "tinyglobby": "^0.2.16", + "unplugin": "^3.0.0", + "unplugin-utils": "^0.3.1" }, "engines": { - "node": ">=14" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/sponsors/antfu" }, "peerDependencies": { - "@babel/parser": "^7.15.8", - "@nuxt/kit": "^3.2.2", - "vue": "2 || 3" + "@nuxt/kit": "^3.2.2 || ^4.0.0", + "vue": "^3.0.0" }, "peerDependenciesMeta": { - "@babel/parser": { - "optional": true - }, "@nuxt/kit": { "optional": true } } }, - "node_modules/unplugin-vue-components/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/unplugin-vue-components/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "node_modules/unplugin-vue-components/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/unplugin-vue-components/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, "engines": { - "node": ">= 8.10.0" + "node": ">=12" }, "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/unplugin-vue-components/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/unplugin-vue-components/node_modules/local-pkg": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.3.tgz", - "integrity": "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/unplugin-vue-components/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/unplugin-vue-components/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "node_modules/unplugin-vue-components/node_modules/unplugin": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.3.0.tgz", + "integrity": "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==", "dev": true, "license": "MIT", "dependencies": { - "picomatch": "^2.2.1" + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.4", + "webpack-virtual-modules": "^0.6.2" }, "engines": { - "node": ">=8.10.0" + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@farmfe/core": "*", + "@rspack/core": "*", + "bun-types-no-globals": "*", + "esbuild": "*", + "rolldown": "*", + "rollup": "*", + "unloader": "*", + "vite": "*", + "webpack": "*" + }, + "peerDependenciesMeta": { + "@farmfe/core": { + "optional": true + }, + "@rspack/core": { + "optional": true + }, + "bun-types-no-globals": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + }, + "unloader": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/unplugin/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/untyped": { @@ -7380,7 +8154,7 @@ "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", - "dev": true, + "devOptional": true, "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", @@ -7468,7 +8242,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -7486,7 +8260,110 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "devOptional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -7502,16 +8379,16 @@ "license": "MIT" }, "node_modules/vue": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", - "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.40.tgz", + "integrity": "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.30", - "@vue/compiler-sfc": "3.5.30", - "@vue/runtime-dom": "3.5.30", - "@vue/server-renderer": "3.5.30", - "@vue/shared": "3.5.30" + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-sfc": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/server-renderer": "3.5.40", + "@vue/shared": "3.5.40" }, "peerDependencies": { "typescript": "*" @@ -7522,6 +8399,12 @@ } } }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.8.tgz", + "integrity": "sha512-troqCMmQodQDqUqn63NQaFi+CDSclSe7sc8VEBFqf5GFLqmGR2Ph3P2WEC7qwpRVyEWsTi/aAr4vyOe/B1hU3g==", + "license": "MIT" + }, "node_modules/vue-eslint-parser": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.0.tgz", @@ -7560,35 +8443,163 @@ } }, "node_modules/vue-router": { - "version": "4.6.4", - "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", - "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.2.0.tgz", + "integrity": "sha512-QAC5i0LEb1GLG0LXDQmHu8L7FX12j0KwU/JTKmLQUJMrn04gQdKP6Du+p0QwpHb3iy71vBlqnHQ8WAfOSAWhqw==", "license": "MIT", "dependencies": { - "@vue/devtools-api": "^6.6.4" + "@babel/generator": "^8.0.0", + "@vue-macros/common": "^3.1.3", + "@vue/devtools-api": "^8.1.5", + "ast-walker-scope": "^0.9.0", + "chokidar": "^5.0.0", + "json5": "^2.2.3", + "local-pkg": "^1.2.1", + "magic-string": "^0.30.21", + "mlly": "^1.8.2", + "muggle-string": "^0.4.1", + "nostics": "^1.1.4", + "pathe": "^2.0.3", + "picomatch": "^4.0.5", + "scule": "^1.3.0", + "tinyglobby": "^0.2.17", + "unplugin": "^3.3.0", + "unplugin-utils": "^0.3.2", + "yaml": "^2.9.0" }, "funding": { "url": "https://github.com/sponsors/posva" }, "peerDependencies": { - "vue": "^3.5.0" + "@pinia/colada": ">=0.21.2", + "@vue/compiler-sfc": "^3.5.34 || ^4.0.0", + "pinia": "^3.0.4 || ^4.0.2", + "vite": "^7.3.0 || ^8.0.0", + "vue": "^3.5.34 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@pinia/colada": { + "optional": true + }, + "@vue/compiler-sfc": { + "optional": true + }, + "pinia": { + "optional": true + }, + "vite": { + "optional": true + } } }, "node_modules/vue-router/node_modules/@vue/devtools-api": { - "version": "6.6.4", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", - "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.2.1.tgz", + "integrity": "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.2.1" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-kit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.2.1.tgz", + "integrity": "sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.2.1", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-shared": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.2.1.tgz", + "integrity": "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==", "license": "MIT" }, + "node_modules/vue-router/node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT" + }, + "node_modules/vue-router/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vue-router/node_modules/unplugin": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.3.0.tgz", + "integrity": "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.4", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@farmfe/core": "*", + "@rspack/core": "*", + "bun-types-no-globals": "*", + "esbuild": "*", + "rolldown": "*", + "rollup": "*", + "unloader": "*", + "vite": "*", + "webpack": "*" + }, + "peerDependenciesMeta": { + "@farmfe/core": { + "optional": true + }, + "@rspack/core": { + "optional": true + }, + "bun-types-no-globals": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + }, + "unloader": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, "node_modules/vue-tsc": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.6.tgz", - "integrity": "sha512-gYW/kWI0XrwGzd0PKc7tVB/qpdeAkIZLNZb10/InizkQjHjnT8weZ/vBarZoj4kHKbUTZT/bAVgoOr8x4NsQ/Q==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.8.tgz", + "integrity": "sha512-xXmYlVQpcwJDWyGlqbHrGVOl1h3UOsASymRibrHc+iy9j/UNnOrOn4u+fntHz4D6Cs74RtapeqVV6CzJeg+UlA==", "dev": true, "license": "MIT", "dependencies": { "@volar/typescript": "2.4.28", - "@vue/language-core": "3.2.6" + "@vue/language-core": "3.3.8" }, "bin": { "vue-tsc": "bin/vue-tsc.js" @@ -7597,13 +8608,60 @@ "typescript": ">=5.0.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", - "dev": true, "license": "MIT" }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7709,6 +8767,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -7719,14 +8794,131 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, "license": "Apache-2.0", "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yocto-queue": { diff --git a/web/package.json b/web/package.json index 5df16a59..a7229979 100644 --- a/web/package.json +++ b/web/package.json @@ -1,5 +1,15 @@ { "name": "frp-web", "private": true, - "workspaces": ["shared", "frpc", "frps"] + "workspaces": ["shared", "frpc", "frps"], + "scripts": { + "test:unit": "vitest run", + "test:unit:watch": "vitest" + }, + "devDependencies": { + "@vue/test-utils": "2.4.11", + "jsdom": "29.1.1", + "vitest": "4.1.10", + "vue": "^3.5.40" + } } diff --git a/web/shared/test/action-button.test.ts b/web/shared/test/action-button.test.ts new file mode 100644 index 00000000..a38629da --- /dev/null +++ b/web/shared/test/action-button.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import ActionButton from '../components/ActionButton.vue' + +describe('ActionButton', () => { + it('emits click events for an enabled button', async () => { + const wrapper = mount(ActionButton, { slots: { default: 'Save' } }) + + await wrapper.trigger('click') + + expect(wrapper.emitted('click')).toHaveLength(1) + expect(wrapper.text()).toBe('Save') + }) + + it('disables itself and shows loading text while loading', async () => { + const wrapper = mount(ActionButton, { + props: { loading: true, loadingText: 'Saving...' }, + slots: { default: 'Save' }, + }) + + await wrapper.trigger('click') + + expect((wrapper.element as HTMLButtonElement).disabled).toBe(true) + expect(wrapper.classes()).toContain('is-loading') + expect(wrapper.find('.spinner').exists()).toBe(true) + expect(wrapper.text()).toBe('Saving...') + expect(wrapper.emitted('click')).toBeUndefined() + }) +}) diff --git a/web/test/setup.test.ts b/web/test/setup.test.ts new file mode 100644 index 00000000..7fef29e1 --- /dev/null +++ b/web/test/setup.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest' + +const capturedFetch = globalThis.fetch + +describe('unit-test environment', () => { + it('blocks real network requests before test modules are evaluated', async () => { + await expect(capturedFetch('https://example.invalid/')).rejects.toThrow( + 'Real network requests are disabled in unit tests', + ) + }) + + it('allows a test to replace fetch explicitly', async () => { + const replacement = vi.fn(async () => new Response('ok')) + vi.stubGlobal('fetch', replacement) + + await expect(fetch('/test-endpoint')).resolves.toBeInstanceOf(Response) + expect(replacement).toHaveBeenCalledWith('/test-endpoint') + }) + + it('restores the network guard after a test replacement', async () => { + await expect(fetch('/must-remain-isolated')).rejects.toThrow( + 'Real network requests are disabled in unit tests', + ) + }) +}) diff --git a/web/test/setup.ts b/web/test/setup.ts new file mode 100644 index 00000000..01421df0 --- /dev/null +++ b/web/test/setup.ts @@ -0,0 +1,25 @@ +import { enableAutoUnmount } from '@vue/test-utils' +import { afterAll, afterEach, vi } from 'vitest' + +const originalFetch = globalThis.fetch +const blockedFetch = vi.fn(async () => { + throw new Error('Real network requests are disabled in unit tests') +}) + +globalThis.fetch = blockedFetch as typeof fetch + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + vi.clearAllMocks() + globalThis.fetch = blockedFetch as typeof fetch + document.body.innerHTML = '' +}) + +// Vitest runs afterEach hooks in reverse registration order, so wrappers are +// unmounted before the cleanup hook above clears the DOM. +enableAutoUnmount(afterEach) + +afterAll(() => { + globalThis.fetch = originalFetch +}) diff --git a/web/vitest.config.mts b/web/vitest.config.mts new file mode 100644 index 00000000..a3ecafc1 --- /dev/null +++ b/web/vitest.config.mts @@ -0,0 +1,40 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + projects: [ + { + extends: './frpc/vite.config.mts', + root: './frpc', + test: { + name: 'frpc', + environment: 'jsdom', + css: true, + server: { + deps: { inline: ['element-plus', '@element-plus/icons-vue'] }, + }, + setupFiles: ['../test/setup.ts'], + include: [ + '../test/**/*.test.ts', + 'test/**/*.test.ts', + '../shared/**/*.test.ts', + ], + }, + }, + { + extends: './frps/vite.config.mts', + root: './frps', + test: { + name: 'frps', + environment: 'jsdom', + css: true, + server: { + deps: { inline: ['element-plus', '@element-plus/icons-vue'] }, + }, + setupFiles: ['../test/setup.ts'], + include: ['test/**/*.test.ts'], + }, + }, + ], + }, +})