feat(web): add standalone WASM config generator (#2480)

Add a small Vite workspace that builds and publishes independently
of the dashboard. Reuse frontend-lib for the form and expose the
existing NetworkConfig conversions through wasm-bindgen.

Initialize the Aura theme in the standalone entry, detect the browser
language, and provide a persistent selector in the form header. Present
Generate Config and Copy Config as the page actions.

Keep the shared network secret field fluid so both form columns align.
Bundle the generator under dist/config-generator in the dashboard
artifact while preserving its standalone build output.

Build the optimized WASM module with the project and remove the
API-backed generator route from the dashboard.
This commit is contained in:
KKRainbow
2026-08-07 16:25:33 +08:00
committed by GitHub
parent 86222771c5
commit e31bde1836
22 changed files with 359 additions and 97 deletions
+8
View File
@@ -9,6 +9,14 @@ rustflags = ["-C", "link-arg=-fuse-ld=mold"]
[target.'cfg(all(windows, target_env = "msvc"))'] [target.'cfg(all(windows, target_env = "msvc"))']
rustflags = ["-C", "target-feature=+crt-static"] rustflags = ["-C", "target-feature=+crt-static"]
[target.wasm32-unknown-unknown]
rustflags = [
"-C",
"opt-level=z",
"--cfg",
'getrandom_backend="wasm_js"',
]
# region # region
# region CI # region CI
+1
View File
@@ -50,6 +50,7 @@ runs:
if: ${{ inputs.pnpm == 'true' }} if: ${{ inputs.pnpm == 'true' }}
uses: ./.github/actions/prepare-pnpm uses: ./.github/actions/prepare-pnpm
with: with:
token: ${{ inputs.token }}
build-filter: ${{ inputs.pnpm-build-filter }} build-filter: ${{ inputs.pnpm-build-filter }}
- name: Install GUI dependencies (Linux) - name: Install GUI dependencies (Linux)
+27
View File
@@ -3,6 +3,9 @@ author: Luna
description: 'Setup Node.js, pnpm, and install dependencies' description: 'Setup Node.js, pnpm, and install dependencies'
inputs: inputs:
token:
description: 'GitHub token, used by setup-protoc action'
required: false
build-filter: build-filter:
description: 'The filter argument for pnpm build (e.g. ./easytier-web/*)' description: 'The filter argument for pnpm build (e.g. ./easytier-web/*)'
required: false required: false
@@ -11,6 +14,22 @@ inputs:
runs: runs:
using: "composite" using: "composite"
steps: steps:
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: 1.95
target: wasm32-unknown-unknown
cache: false
rustflags: ''
- uses: taiki-e/install-action@v2
with:
tool: wasm-pack
- uses: arduino/setup-protoc@v3
with:
version: '35.1'
repo-token: ${{ inputs.token }}
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v5 uses: actions/setup-node@v5
with: with:
@@ -46,3 +65,11 @@ runs:
echo "No build filter provided, building all packages" echo "No build filter provided, building all packages"
pnpm -r --workspace-concurrency=1 build pnpm -r --workspace-concurrency=1 build
fi fi
- name: Bundle config generator with web frontend
shell: bash
run: |
if [ -f easytier-web/frontend/dist/index.html ] && [ -f easytier-web/config-generator/dist/index.html ]; then
mkdir -p easytier-web/frontend/dist/config-generator
cp -R easytier-web/config-generator/dist/. easytier-web/frontend/dist/config-generator/
fi
+1
View File
@@ -47,6 +47,7 @@ jobs:
- name: Setup Frontend Environment - name: Setup Frontend Environment
uses: ./.github/actions/prepare-pnpm uses: ./.github/actions/prepare-pnpm
with: with:
token: ${{ github.token }}
build-filter: './easytier-web/*' build-filter: './easytier-web/*'
- name: Archive artifact - name: Archive artifact
+2
View File
@@ -35,6 +35,8 @@ easytier-panic.log
# web # web
node_modules node_modules
easytier-web/frontend-lib/src/generated/ easytier-web/frontend-lib/src/generated/
easytier-web/config-generator/dist/
easytier-web/config-generator/src/generated/
.vite .vite
Generated
+3
View File
@@ -2434,6 +2434,8 @@ dependencies = [
"derive_builder", "derive_builder",
"easytier-proto", "easytier-proto",
"futures", "futures",
"getrandom 0.2.15",
"getrandom 0.3.2",
"guarden 0.2.0", "guarden 0.2.0",
"hmac", "hmac",
"http-body-util", "http-body-util",
@@ -2468,6 +2470,7 @@ dependencies = [
"tracing", "tracing",
"url", "url",
"uuid", "uuid",
"wasm-bindgen",
"webpki-roots 0.26.3", "webpki-roots 0.26.3",
"wildmatch", "wildmatch",
"x25519-dalek", "x25519-dalek",
+14 -1
View File
@@ -14,6 +14,9 @@ license-file = "../LICENSE"
[lib] [lib]
crate-type = ["rlib", "cdylib"] crate-type = ["rlib", "cdylib"]
[package.metadata.wasm-pack.profile.release]
wasm-opt = ["-Oz", "--enable-bulk-memory", "--enable-nontrapping-float-to-int"]
[dependencies] [dependencies]
anyhow = "1.0" anyhow = "1.0"
ariadne = { version = "0.5", optional = true } ariadne = { version = "0.5", optional = true }
@@ -55,7 +58,6 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1" serde_json = "1"
sha2 = "0.10.8" sha2 = "0.10.8"
smoltcp = { git = "https://github.com/smoltcp-rs/smoltcp.git", rev = "0a926767a68bc88d5512afefa7529c5ecdade4ea", optional = true, default-features = false } smoltcp = { git = "https://github.com/smoltcp-rs/smoltcp.git", rev = "0a926767a68bc88d5512afefa7529c5ecdade4ea", optional = true, default-features = false }
snow = "0.10.0"
stun_codec = "0.3.4" stun_codec = "0.3.4"
thiserror = "1.0" thiserror = "1.0"
tracing = "0.1" tracing = "0.1"
@@ -81,9 +83,20 @@ aes-gcm = { version = "0.10.3", optional = true }
chacha20poly1305 = { version = "0.10.1", optional = true } chacha20poly1305 = { version = "0.10.1", optional = true }
openssl = { version = "0.10", optional = true, features = ["vendored"] } openssl = { version = "0.10", optional = true, features = ["vendored"] }
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
getrandom-02 = { package = "getrandom", version = "0.2.15", features = ["js"] }
getrandom-03 = { package = "getrandom", version = "0.3.2", features = ["wasm_js"] }
snow = { version = "0.10.0", default-features = false, features = ["default-resolver", "default-resolver-crypto"] }
uuid = { version = "1.5.0", features = ["js"] }
wasm-bindgen = "0.2"
[target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies]
snow = "0.10.0"
[features] [features]
default = ["aes-gcm", "endpoint-discovery", "extended-services", "management", "tcp-hole-punch"] default = ["aes-gcm", "endpoint-discovery", "extended-services", "management", "tcp-hole-punch"]
aes-gcm = ["dep:aes-gcm"] aes-gcm = ["dep:aes-gcm"]
browser-config = ["config-write", "easytier-proto/api", "easytier-proto/json-rpc"]
chacha20 = ["dep:chacha20poly1305"] chacha20 = ["dep:chacha20poly1305"]
openssl-crypto = ["dep:openssl"] openssl-crypto = ["dep:openssl"]
ring-crypto = ["dep:ring"] ring-crypto = ["dep:ring"]
+27
View File
@@ -0,0 +1,27 @@
use wasm_bindgen::prelude::*;
use super::{
api_input::{NetworkConfig, NetworkConfigExt},
toml::{ConfigLoader, TomlConfig},
};
fn js_error(error: impl std::fmt::Debug) -> JsValue {
JsValue::from_str(&format!("{error:?}"))
}
#[wasm_bindgen]
pub fn generate_config(config_json: &str) -> Result<String, JsValue> {
let config: NetworkConfig = serde_json::from_str(config_json).map_err(js_error)?;
config
.gen_config()
.map(|config| config.dump())
.map_err(js_error)
}
#[wasm_bindgen]
pub fn parse_config(toml_config: &str) -> Result<String, JsValue> {
let config = TomlConfig::new_from_str(toml_config)
.and_then(|config| NetworkConfig::new_from_config(&config))
.map_err(js_error)?;
serde_json::to_string(&config).map_err(js_error)
}
+7 -1
View File
@@ -2,8 +2,14 @@
#[cfg(feature = "management")] #[cfg(feature = "management")]
pub mod api; pub mod api;
#[cfg(feature = "management")] #[cfg(any(feature = "management", feature = "browser-config"))]
pub mod api_input; pub mod api_input;
#[cfg(all(
feature = "browser-config",
target_arch = "wasm32",
target_os = "unknown"
))]
mod browser;
mod encryption; mod encryption;
pub mod gateway; pub mod gateway;
pub mod peers; pub mod peers;
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EasyTier Config Generator</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
@@ -0,0 +1,24 @@
{
"name": "easytier-config-generator",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"build:config-wasm": "wasm-pack build ../../easytier-core --target web --out-dir ../easytier-web/config-generator/src/generated/config-wasm --out-name easytier_config --release --no-pack -- --no-default-features --features browser-config",
"dev": "pnpm build:config-wasm && pnpm --dir ../frontend-lib build && vite",
"build": "pnpm build:config-wasm && pnpm --dir ../frontend-lib build && vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@primeuix/themes": "^1.2.3",
"easytier-frontend-lib": "workspace:*",
"primevue": "^4.3.9",
"vue": "^3.5.12"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.4",
"typescript": "~5.6.2",
"vite": "^5.4.21",
"vue-tsc": "^2.1.10"
}
}
+151
View File
@@ -0,0 +1,151 @@
<script setup lang="ts">
import { Config, I18nUtils, NetworkTypes } from 'easytier-frontend-lib'
import { Button, SelectButton, Textarea } from 'primevue'
import { computed, onMounted, ref, watch } from 'vue'
import initConfigWasm, {
generate_config as generateTomlConfig,
} from './generated/config-wasm/easytier_config'
let configWasmReady: ReturnType<typeof initConfigWasm> | undefined
const ensureConfigWasm = () => configWasmReady ??= initConfigWasm()
const networkConfig = ref<NetworkTypes.NetworkConfig>(NetworkTypes.DEFAULT_NETWORK_CONFIG())
const tomlConfig = ref('')
const errorMessage = ref('')
const configCopied = ref(false)
const t = (key: string) => I18nUtils.i18n.global.t(key)
const copyButtonLabel = computed(() => t(configCopied.value ? 'config_copied' : 'copy_config'))
type Language = 'cn' | 'en'
const languageOptions: { label: string, value: Language }[] = [
{ label: '中文', value: 'cn' },
{ label: 'EN', value: 'en' },
]
const currentLanguage = computed(() => I18nUtils.i18n.global.locale.value as Language)
watch(tomlConfig, () => {
configCopied.value = false
})
onMounted(async () => {
const savedLanguage = localStorage.getItem('lang')
const language = savedLanguage === 'cn' || savedLanguage === 'en'
? savedLanguage
: navigator.language.toLowerCase().startsWith('zh') ? 'cn' : 'en'
await I18nUtils.loadLanguageAsync(language)
})
const setLanguage = (language: Language) => {
if (language !== currentLanguage.value) {
void I18nUtils.loadLanguageAsync(language)
}
}
const generateConfig = async (config: NetworkTypes.NetworkConfig) => {
try {
errorMessage.value = ''
tomlConfig.value = ''
const configJson = JSON.stringify(NetworkTypes.toBackendNetworkConfig(config))
await ensureConfigWasm()
tomlConfig.value = generateTomlConfig(configJson)
} catch (error) {
errorMessage.value = t('config_generation_failed') + ': ' + (error instanceof Error ? error.message : String(error))
}
}
const copyConfig = async () => {
try {
errorMessage.value = ''
configCopied.value = false
await navigator.clipboard.writeText(tomlConfig.value)
configCopied.value = true
} catch (error) {
errorMessage.value = t('config_copy_failed') + ': ' + (error instanceof Error ? error.message : String(error))
}
}
</script>
<template>
<main class="config-generator">
<section class="config-panel">
<SelectButton
class="language-switch"
:model-value="currentLanguage"
:options="languageOptions"
option-label="label"
option-value="value"
size="small"
:allow-empty="false"
:aria-label="t('exchange_language')"
@update:model-value="setLanguage"
/>
<Config :cur-network="networkConfig" :action-label="t('generate_config')" @run-network="generateConfig" />
</section>
<section class="output-panel">
<pre v-if="errorMessage" class="error-message">{{ errorMessage }}</pre>
<Textarea
v-model="tomlConfig"
spellcheck="false"
class="toml-config"
:placeholder="t('config_generator_placeholder')"
/>
<Button :label="copyButtonLabel" icon="pi pi-copy" :disabled="!tomlConfig" @click="copyConfig" />
</section>
</main>
</template>
<style scoped>
.config-generator {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 2rem;
padding: 1.25rem;
}
.config-panel {
position: relative;
}
.language-switch {
position: absolute;
z-index: 2;
top: 0.4rem;
right: 0.75rem;
}
.config-panel,
.output-panel {
min-width: 0;
}
.output-panel {
display: flex;
flex-direction: column;
gap: 0.75rem;
height: calc(100vh - 2.5rem);
height: calc(100dvh - 2.5rem);
}
.toml-config {
width: 100%;
flex: 1;
resize: none;
font-family: monospace;
}
.error-message {
max-height: 10rem;
overflow: auto;
padding: 0.5rem;
color: #b91c1c;
background: #fee2e2;
border-radius: 0.25rem;
}
@media (max-width: 768px) {
.config-generator {
grid-template-columns: 1fr;
gap: 1.25rem;
padding: 0.75rem;
}
}
</style>
+23
View File
@@ -0,0 +1,23 @@
import { createApp } from 'vue'
import Aura from '@primeuix/themes/aura'
import EasytierFrontendLib from 'easytier-frontend-lib'
import 'easytier-frontend-lib/style.css'
import PrimeVue from 'primevue/config'
import App from './App.vue'
createApp(App)
.use(PrimeVue, {
theme: {
preset: Aura,
options: {
prefix: 'p',
darkModeSelector: 'system',
cssLayer: {
name: 'primevue',
order: 'tailwind-base, primevue, tailwind-utilities',
},
},
},
})
.use(EasytierFrontendLib)
.mount('#app')
@@ -0,0 +1,8 @@
{
"extends": "../frontend/tsconfig.app.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
@@ -0,0 +1,7 @@
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
export default defineConfig({
base: './',
plugins: [vue()],
})
@@ -15,6 +15,7 @@ import AclManager from './acl/AclManager.vue'
import UrlListInput from './UrlListInput.vue' import UrlListInput from './UrlListInput.vue'
const props = defineProps<{ const props = defineProps<{
actionLabel?: string
configInvalid?: boolean configInvalid?: boolean
hostname?: string hostname?: string
}>() }>()
@@ -235,7 +236,7 @@ const instanceRecvBpsLimitInput = computed<string>({
<div class="flex flex-col gap-2 basis-5/12 grow"> <div class="flex flex-col gap-2 basis-5/12 grow">
<label for="network_secret">{{ t('network_secret') }}</label> <label for="network_secret">{{ t('network_secret') }}</label>
<Password id="network_secret" v-model="curNetwork.network_secret" <Password id="network_secret" v-model="curNetwork.network_secret"
aria-describedby="network_secret-help" toggleMask :feedback="false" /> aria-describedby="network_secret-help" toggleMask :feedback="false" fluid />
</div> </div>
</div> </div>
@@ -539,7 +540,7 @@ const instanceRecvBpsLimitInput = computed<string>({
</Panel> </Panel>
<div class="flex pt-6 justify-center"> <div class="flex pt-6 justify-center">
<Button :label="t('run_network')" icon="pi pi-arrow-right" icon-pos="right" :disabled="configInvalid" <Button :label="actionLabel || t('run_network')" icon="pi pi-arrow-right" icon-pos="right" :disabled="configInvalid"
@click="$emit('runNetwork', curNetwork)" /> @click="$emit('runNetwork', curNetwork)" />
</div> </div>
</div> </div>
@@ -43,6 +43,12 @@ network_instances: 网络实例
instance_id: 实例ID instance_id: 实例ID
network_infos: 网络信息 network_infos: 网络信息
parse_network_config: 解析网络配置 parse_network_config: 解析网络配置
config_generator_placeholder: 生成的 TOML 配置将显示在这里
generate_config: 生成配置
copy_config: 复制配置
config_copied: 已复制
config_generation_failed: 生成失败
config_copy_failed: 复制失败
retain_network_instance: 保留网络实例 retain_network_instance: 保留网络实例
collect_network_infos: 收集网络信息 collect_network_infos: 收集网络信息
settings: 设置 settings: 设置
@@ -43,6 +43,12 @@ network_instances: Network Instances
instance_id: Instance ID instance_id: Instance ID
network_infos: Network Infos network_infos: Network Infos
parse_network_config: Parse Network Config parse_network_config: Parse Network Config
config_generator_placeholder: Generated TOML configuration appears here
generate_config: Generate Config
copy_config: Copy Config
config_copied: Copied
config_generation_failed: Generation failed
config_copy_failed: Copy failed
retain_network_instance: Retain Network Instance retain_network_instance: Retain Network Instance
collect_network_infos: Collect Network Infos collect_network_infos: Collect Network Infos
settings: Settings settings: Settings
@@ -1,88 +0,0 @@
<script setup lang="ts">
import { NetworkTypes } from 'easytier-frontend-lib';
import { computed, ref } from 'vue';
import { AutoComplete, Divider, Button, Textarea } from "primevue";
import { getInitialApiHost, cleanAndLoadApiHosts, saveApiHost } from "../modules/api-host"
import ApiClient from '../modules/api';
const api = computed<ApiClient>(() => new ApiClient(apiHost.value));
const apiHost = ref<string>(getInitialApiHost())
const apiHostSuggestions = ref<Array<string>>([])
const apiHostSearch = async (event: { query: string }) => {
apiHostSuggestions.value = [];
let hosts = cleanAndLoadApiHosts();
if (event.query) {
apiHostSuggestions.value.push(event.query);
}
hosts.forEach((host) => {
apiHostSuggestions.value.push(host.value);
});
}
const newNetworkConfig = ref<NetworkTypes.NetworkConfig>(NetworkTypes.DEFAULT_NETWORK_CONFIG());
const toml_config = ref<string>("");
const errorMessage = ref<string>("");
const generateConfig = (config: NetworkTypes.NetworkConfig) => {
saveApiHost(apiHost.value)
errorMessage.value = "";
api.value?.get_remote_client("").generate_config(config).then((res) => {
if (res.error) {
errorMessage.value = "Generation failed: " + res.error;
} else if (res.toml_config) {
toml_config.value = res.toml_config;
} else {
errorMessage.value = "Api server returned an unexpected response";
}
}).catch(err => {
errorMessage.value = "Generate request failed: " + (err instanceof Error ? err.message : String(err));
});
};
const parseConfig = async () => {
try {
errorMessage.value = "";
const res = await api.value?.get_remote_client("").parse_config(toml_config.value);
if (res.error) {
errorMessage.value = "Parse failed: " + res.error;
} else if (res.config) {
newNetworkConfig.value = res.config;
} else {
errorMessage.value = "API returned an unexpected response";
}
} catch (e) {
errorMessage.value = "Parse request failed: " + (e instanceof Error ? e.message : String(e));
}
};
</script>
<template>
<div class="flex items-center justify-center m-5">
<div class="sm:block md:flex w-full">
<div class="sm:w-full md:w-1/2 p-4">
<div class="flex flex-col">
<div class="w-full self-center ">
<label>ApiHost</label>
<AutoComplete id="api-host" v-model="apiHost" dropdown :suggestions="apiHostSuggestions"
@complete="apiHostSearch" class="w-full" />
<Divider />
</div>
</div>
<Config :cur-network="newNetworkConfig" @run-network="generateConfig" />
</div>
<div class="sm:w-full md:w-1/2 p-4 flex flex-col h-[calc(100vh-80px)]">
<pre v-if="errorMessage"
class="mb-2 p-2 rounded text-sm overflow-auto bg-red-100 text-red-700 max-h-40">{{ errorMessage }}</pre>
<Textarea v-model="toml_config" spellcheck="false"
class="w-full flex-grow p-2 whitespace-pre-wrap font-mono resize-none"
placeholder="Press 'Run Network' to generate TOML configuration, or paste your TOML configuration here to parse it"></Textarea>
<div class="mt-3 flex justify-center">
<Button label="Parse Config" icon="pi pi-arrow-left" icon-pos="left" @click="parseConfig" />
</div>
</div>
</div>
</div>
</template>
-5
View File
@@ -16,7 +16,6 @@ import DeviceManagement from './components/DeviceManagement.vue'
import Dashboard from './components/Dashboard.vue' import Dashboard from './components/Dashboard.vue'
import DialogService from 'primevue/dialogservice'; import DialogService from 'primevue/dialogservice';
import ToastService from 'primevue/toastservice'; import ToastService from 'primevue/toastservice';
import ConfigGenerator from './components/ConfigGenerator.vue'
const routes = [ const routes = [
{ {
@@ -67,10 +66,6 @@ const routes = [
return { name: 'login' } return { name: 'login' }
} }
} }
},
{
path: '/config_generator',
component: ConfigGenerator,
} }
] ]
+28
View File
@@ -145,6 +145,34 @@ importers:
specifier: ^2.1.10 specifier: ^2.1.10
version: 2.2.12(typescript@5.6.3) version: 2.2.12(typescript@5.6.3)
easytier-web/config-generator:
dependencies:
'@primeuix/themes':
specifier: ^1.2.3
version: 1.2.3
easytier-frontend-lib:
specifier: workspace:*
version: link:../frontend-lib
primevue:
specifier: ^4.3.9
version: 4.3.9(vue@3.5.21(typescript@5.6.3))
vue:
specifier: ^3.5.12
version: 3.5.21(typescript@5.6.3)
devDependencies:
'@vitejs/plugin-vue':
specifier: ^5.1.4
version: 5.2.4(vite@5.4.21(@types/node@22.18.1))(vue@3.5.21(typescript@5.6.3))
typescript:
specifier: ~5.6.2
version: 5.6.3
vite:
specifier: ^5.4.21
version: 5.4.21(@types/node@22.18.1)
vue-tsc:
specifier: ^2.1.10
version: 2.2.12(typescript@5.6.3)
easytier-web/frontend: easytier-web/frontend:
dependencies: dependencies:
'@modyfi/vite-plugin-yaml': '@modyfi/vite-plugin-yaml':
+1
View File
@@ -1,4 +1,5 @@
packages: packages:
- 'easytier-web/config-generator'
- 'easytier-web/frontend' - 'easytier-web/frontend'
- 'easytier-web/frontend-lib' - 'easytier-web/frontend-lib'
- 'easytier-gui' - 'easytier-gui'