3x-ui/docs/superpowers/plans/2026-04-07-cloudflare-cdn-assets.md
root f5862abc2e feat: add CodeMirror YAML editor for Clash template and fix settings save button bug
- Replace plain textarea with CodeMirror editor (YAML syntax highlighting, line numbers, auto-indent) for Clash subscription template
- Fix confAlerts crash when subClashURI/subURI/subJsonURI is null/undefined (prevented save button from enabling)
- Add yaml.js CodeMirror mode asset
- Include docs and .gitignore cleanup
2026-04-24 16:15:22 +08:00

21 KiB

Cloudflare CDN Assets Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add build-time fingerprinted frontend assets, manifest-based template asset resolution, and Cloudflare-friendly cache headers without changing panel behavior.

Architecture: Introduce a small Go asset-generation package plus a CLI that transforms web/assets into fingerprinted files under web/public/assets and emits web/public/assets-manifest.json. Update the web server to load the manifest in production, expose an asset template helper, serve the generated embedded files, and separate HTML caching from immutable asset caching.

Tech Stack: Go 1.26, go:embed, Gin, standard library crypto/sha256, encoding/json, html/template, Go tests


File Structure

  • Create: web/assetsgen/generator.go
  • Create: web/assetsgen/generator_test.go
  • Create: cmd/genassets/main.go
  • Create: web/public/.gitkeep
  • Create: web/public/README.md
  • Create: web/asset_manifest.go
  • Create: web/asset_manifest_test.go
  • Modify: web/web.go
  • Modify: web/html/common/page.html
  • Modify: web/html/component/aPersianDatepicker.html
  • Modify: web/html/inbounds.html
  • Modify: web/html/settings/panel/subscription/subpage.html
  • Modify: web/html/settings.html
  • Modify: web/html/xray.html
  • Modify: Dockerfile
  • Modify: README.md

Task 1: Build Fingerprinted Asset Generator

Files:

  • Create: web/assetsgen/generator.go

  • Test: web/assetsgen/generator_test.go

  • Step 1: Write the failing generator tests

package assetsgen

import (
	"encoding/json"
	"os"
	"path/filepath"
	"testing"
)

func TestGenerateProducesFingerprintManifestAndFiles(t *testing.T) {
	src := t.TempDir()
	dst := t.TempDir()

	if err := os.MkdirAll(filepath.Join(src, "js"), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(src, "js", "app.js"), []byte("console.log('v1')\n"), 0o644); err != nil {
		t.Fatal(err)
	}

	manifest, err := Generate(Options{
		SourceDir: src,
		OutputDir: filepath.Join(dst, "assets"),
		HashLen:   8,
	})
	if err != nil {
		t.Fatalf("Generate returned error: %v", err)
	}

	got, ok := manifest["js/app.js"]
	if !ok {
		t.Fatalf("manifest missing logical path: %#v", manifest)
	}
	if got == "js/app.js" {
		t.Fatalf("expected hashed filename, got %q", got)
	}

	if _, err := os.Stat(filepath.Join(dst, "assets", got)); err != nil {
		t.Fatalf("hashed output missing: %v", err)
	}
}

func TestWriteManifestSerializesStableJson(t *testing.T) {
	dst := t.TempDir()
	path := filepath.Join(dst, "assets-manifest.json")
	manifest := Manifest{
		"css/a.css": "css/a.11111111.css",
		"js/b.js":   "js/b.22222222.js",
	}

	if err := WriteManifest(path, manifest); err != nil {
		t.Fatalf("WriteManifest returned error: %v", err)
	}

	raw, err := os.ReadFile(path)
	if err != nil {
		t.Fatal(err)
	}

	var decoded map[string]string
	if err := json.Unmarshal(raw, &decoded); err != nil {
		t.Fatalf("manifest json invalid: %v", err)
	}
	if decoded["js/b.js"] != "js/b.22222222.js" {
		t.Fatalf("unexpected manifest entry: %#v", decoded)
	}
}
  • Step 2: Run the generator tests to verify they fail

Run: go test ./web/assetsgen -run 'TestGenerate|TestWriteManifest' -count=1

Expected: FAIL with undefined Generate, Options, Manifest, or WriteManifest.

  • Step 3: Write the minimal generator implementation
package assetsgen

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"io/fs"
	"os"
	"path/filepath"
	"sort"
	"strings"
)

type Manifest map[string]string

type Options struct {
	SourceDir string
	OutputDir string
	HashLen   int
}

func Generate(opts Options) (Manifest, error) {
	if opts.HashLen <= 0 {
		opts.HashLen = 8
	}

	manifest := make(Manifest)
	if err := os.RemoveAll(opts.OutputDir); err != nil {
		return nil, err
	}
	if err := os.MkdirAll(opts.OutputDir, 0o755); err != nil {
		return nil, err
	}

	err := filepath.WalkDir(opts.SourceDir, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() {
			return nil
		}

		rel, err := filepath.Rel(opts.SourceDir, path)
		if err != nil {
			return err
		}
		rel = filepath.ToSlash(rel)

		raw, err := os.ReadFile(path)
		if err != nil {
			return err
		}

		sum := sha256.Sum256(raw)
		hash := hex.EncodeToString(sum[:])[:opts.HashLen]
		target := fingerprint(rel, hash)
		targetPath := filepath.Join(opts.OutputDir, filepath.FromSlash(target))

		if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
			return err
		}
		if err := os.WriteFile(targetPath, raw, 0o644); err != nil {
			return err
		}

		manifest[rel] = target
		return nil
	})
	if err != nil {
		return nil, err
	}

	return manifest, nil
}

func WriteManifest(path string, manifest Manifest) error {
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return err
	}

	keys := make([]string, 0, len(manifest))
	for key := range manifest {
		keys = append(keys, key)
	}
	sort.Strings(keys)

	ordered := make(map[string]string, len(keys))
	for _, key := range keys {
		ordered[key] = manifest[key]
	}

	raw, err := json.MarshalIndent(ordered, "", "  ")
	if err != nil {
		return err
	}
	raw = append(raw, '\n')
	return os.WriteFile(path, raw, 0o644)
}

func fingerprint(rel, hash string) string {
	ext := filepath.Ext(rel)
	base := strings.TrimSuffix(rel, ext)
	if ext == "" {
		return rel + "." + hash
	}
	return base + "." + hash + ext
}

func CopyFile(dst string, src io.Reader) error {
	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
		return err
	}
	f, err := os.Create(dst)
	if err != nil {
		return err
	}
	defer f.Close()
	_, err = io.Copy(f, src)
	return err
}
  • Step 4: Run the generator tests to verify they pass

Run: go test ./web/assetsgen -run 'TestGenerate|TestWriteManifest' -count=1

Expected: PASS

  • Step 5: Commit the generator package
git add web/assetsgen/generator.go web/assetsgen/generator_test.go
git commit -m "feat: add fingerprinted asset generator"

Task 2: Add Generator CLI and Generated Output Conventions

Files:

  • Create: cmd/genassets/main.go

  • Create: web/public/.gitkeep

  • Create: web/public/README.md

  • Test: web/assetsgen/generator_test.go

  • Step 1: Extend tests for nested paths and hash placement

func TestGeneratePreservesNestedDirectories(t *testing.T) {
	src := t.TempDir()
	dst := t.TempDir()

	if err := os.MkdirAll(filepath.Join(src, "css"), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(src, "css", "custom.min.css"), []byte("body{}\n"), 0o644); err != nil {
		t.Fatal(err)
	}

	manifest, err := Generate(Options{
		SourceDir: src,
		OutputDir: filepath.Join(dst, "assets"),
		HashLen:   8,
	})
	if err != nil {
		t.Fatalf("Generate returned error: %v", err)
	}

	got := manifest["css/custom.min.css"]
	if got == "" {
		t.Fatalf("missing css/custom.min.css entry: %#v", manifest)
	}
	if filepath.Dir(got) != "css" {
		t.Fatalf("expected nested output directory, got %q", got)
	}
	if filepath.Ext(got) != ".css" {
		t.Fatalf("expected css extension, got %q", got)
	}
}
  • Step 2: Run the test to verify the generator still drives the behavior

Run: go test ./web/assetsgen -run TestGeneratePreservesNestedDirectories -count=1

Expected: PASS after Task 1, confirming the generator already satisfies the nested-path rule.

  • Step 3: Add the CLI entrypoint
package main

import (
	"log"
	"path/filepath"

	"github.com/mhsanaei/3x-ui/v2/web/assetsgen"
)

func main() {
	const (
		sourceDir    = "web/assets"
		outputDir    = "web/public/assets"
		manifestPath = "web/public/assets-manifest.json"
	)

	manifest, err := assetsgen.Generate(assetsgen.Options{
		SourceDir: sourceDir,
		OutputDir: outputDir,
		HashLen:   8,
	})
	if err != nil {
		log.Fatalf("generate fingerprinted assets: %v", err)
	}

	if err := assetsgen.WriteManifest(filepath.Clean(manifestPath), manifest); err != nil {
		log.Fatalf("write asset manifest: %v", err)
	}
}
  • Step 4: Add generated-output conventions

web/public/README.md

# Generated frontend assets

This directory is generated from `web/assets` by:

- `go run ./cmd/genassets`

Contents:

- `assets/`: fingerprinted files for production embedding
- `assets-manifest.json`: logical-to-fingerprinted path mapping

Do not edit generated files by hand.

web/public/.gitkeep


  • Step 5: Verify the CLI generates output

Run: go run ./cmd/genassets

Expected: web/public/assets-manifest.json exists and web/public/assets/ contains hashed files.

  • Step 6: Commit the CLI and generated-output convention
git add cmd/genassets/main.go web/public/.gitkeep web/public/README.md
git commit -m "feat: add asset generation command"

Task 3: Load Asset Manifest and Serve Fingerprinted Assets

Files:

  • Create: web/asset_manifest.go

  • Test: web/asset_manifest_test.go

  • Modify: web/web.go

  • Step 1: Write the failing manifest and helper tests

package web

import (
	"strings"
	"testing"
)

func TestAssetResolverReturnsFingerprintedPathInProduction(t *testing.T) {
	resolver := newAssetResolver("/panel/", false, assetManifest{
		"js/websocket.js": "js/websocket.12345678.js",
	})

	got := resolver.URL("js/websocket.js")
	want := "/panel/assets/js/websocket.12345678.js"
	if got != want {
		t.Fatalf("expected %q, got %q", want, got)
	}
}

func TestAssetResolverReturnsLogicalPathInDebug(t *testing.T) {
	resolver := newAssetResolver("/panel/", true, nil)

	got := resolver.URL("js/websocket.js")
	want := "/panel/assets/js/websocket.js"
	if got != want {
		t.Fatalf("expected %q, got %q", want, got)
	}
}

func TestAssetResolverPanicsOnMissingProductionAsset(t *testing.T) {
	resolver := newAssetResolver("/", false, assetManifest{})

	defer func() {
		if r := recover(); r == nil {
			t.Fatal("expected panic for missing manifest key")
		}
	}()

	resolver.URL("missing.js")
}

func TestFingerprintCacheHeaderIncludesImmutable(t *testing.T) {
	got := assetCacheControl("js/websocket.12345678.js")
	if !strings.Contains(got, "immutable") {
		t.Fatalf("expected immutable cache-control, got %q", got)
	}
}
  • Step 2: Run the web tests to verify they fail

Run: go test ./web -run 'TestAssetResolver|TestFingerprintCacheHeader' -count=1

Expected: FAIL with undefined newAssetResolver, assetManifest, or assetCacheControl.

  • Step 3: Add the manifest loader and resolver

web/asset_manifest.go

package web

import (
	"encoding/json"
	"fmt"
	"path"
	"strings"
)

type assetManifest map[string]string

type assetResolver struct {
	basePath string
	debug    bool
	manifest assetManifest
}

func newAssetResolver(basePath string, debug bool, manifest assetManifest) assetResolver {
	return assetResolver{
		basePath: basePath,
		debug:    debug,
		manifest: manifest,
	}
}

func (r assetResolver) URL(logical string) string {
	target := logical
	if !r.debug {
		hashed, ok := r.manifest[logical]
		if !ok {
			panic(fmt.Sprintf("missing asset manifest entry for %q", logical))
		}
		target = hashed
	}
	return path.Join(r.basePath, "assets", target)
}

func loadAssetManifest(raw []byte) (assetManifest, error) {
	if len(raw) == 0 {
		return nil, fmt.Errorf("asset manifest is empty")
	}
	var manifest assetManifest
	if err := json.Unmarshal(raw, &manifest); err != nil {
		return nil, err
	}
	if len(manifest) == 0 {
		return nil, fmt.Errorf("asset manifest has no entries")
	}
	return manifest, nil
}

func assetCacheControl(requestPath string) string {
	if hasFingerprint(requestPath) {
		return "public, max-age=31536000, immutable"
	}
	return "public, max-age=300"
}

func hasFingerprint(requestPath string) bool {
	base := path.Base(requestPath)
	parts := strings.Split(base, ".")
	if len(parts) < 3 {
		return false
	}
	hash := parts[len(parts)-2]
	if len(hash) != 8 {
		return false
	}
	for _, ch := range hash {
		if !strings.ContainsRune("0123456789abcdef", ch) {
			return false
		}
	}
	return true
}
  • Step 4: Wire manifest loading and static serving into web/web.go

Add or update the embedded assets section and router setup with code shaped like this:

//go:embed public/assets
var publicAssetsFS embed.FS

//go:embed public/assets-manifest.json
var assetsManifestRaw []byte

var productionAssetManifest assetManifest

func init() {
	if config.IsDebug() {
		return
	}
	manifest, err := loadAssetManifest(assetsManifestRaw)
	if err != nil {
		panic(err)
	}
	productionAssetManifest = manifest
}

In initRouter, register the helper and cache policy:

assetResolver := newAssetResolver(basePath, config.IsDebug(), productionAssetManifest)

funcMap := template.FuncMap{
	"i18n":  i18nWebFunc,
	"asset": assetResolver.URL,
}

engine.Use(func(c *gin.Context) {
	uri := c.Request.URL.Path
	if strings.HasPrefix(uri, assetsBasePath) {
		c.Header("Cache-Control", assetCacheControl(uri))
		return
	}
	if c.Request.Method == http.MethodGet {
		c.Header("Cache-Control", "no-cache, must-revalidate")
	}
})

And switch the production static filesystem to:

engine.StaticFS(basePath+"assets", http.FS(&wrapAssetsFS{FS: publicAssetsFS}))
  • Step 5: Run the web tests to verify they pass

Run: go test ./web -run 'TestAssetResolver|TestFingerprintCacheHeader' -count=1

Expected: PASS

  • Step 6: Commit the server-side asset resolution changes
git add web/asset_manifest.go web/asset_manifest_test.go web/web.go
git commit -m "feat: load fingerprinted asset manifest"

Task 4: Update Templates to Use Manifest-Based Asset URLs

Files:

  • Modify: web/html/common/page.html

  • Modify: web/html/component/aPersianDatepicker.html

  • Modify: web/html/inbounds.html

  • Modify: web/html/settings/panel/subscription/subpage.html

  • Modify: web/html/settings.html

  • Modify: web/html/xray.html

  • Test: web/asset_manifest_test.go

  • Step 1: Add a template-focused regression test

Append this test to web/asset_manifest_test.go:

func TestAssetResolverPreservesBasePathWithoutDoubleSlash(t *testing.T) {
	resolver := newAssetResolver("/xui/", false, assetManifest{
		"css/custom.min.css": "css/custom.min.11111111.css",
	})

	got := resolver.URL("css/custom.min.css")
	want := "/xui/assets/css/custom.min.11111111.css"
	if got != want {
		t.Fatalf("expected %q, got %q", want, got)
	}
}
  • Step 2: Run the focused test before template edits

Run: go test ./web -run TestAssetResolverPreservesBasePathWithoutDoubleSlash -count=1

Expected: PASS after Task 3, confirming the helper output is safe to roll through templates.

  • Step 3: Replace direct asset URLs in shared page template

Update web/html/common/page.html references like this:

<link rel="stylesheet" href="{{ asset "ant-design-vue/antd.min.css" }}">
<link rel="stylesheet" href="{{ asset "css/custom.min.css" }}">
src: url('{{ asset "Vazirmatn-UI-NL-Regular.woff2" }}') format('woff2');
<script src="{{ asset "vue/vue.min.js" }}"></script>
<script src="{{ asset "moment/moment.min.js" }}"></script>
<script src="{{ asset "ant-design-vue/antd.min.js" }}"></script>
<script src="{{ asset "axios/axios.min.js" }}"></script>
<script src="{{ asset "qs/qs.min.js" }}"></script>
<script src="{{ asset "js/axios-init.js" }}"></script>
<script src="{{ asset "js/util/index.js" }}"></script>
<script src="{{ asset "js/websocket.js" }}"></script>
  • Step 4: Replace page-specific asset URLs

Apply the same conversion in these files:

web/html/component/aPersianDatepicker.html

<link rel="stylesheet" href="{{ asset "persian-datepicker/persian-datepicker.min.css" }}" />
<script src="{{ asset "moment/moment-jalali.min.js" }}"></script>
<script src="{{ asset "persian-datepicker/persian-datepicker.min.js" }}"></script>

web/html/inbounds.html

<script src="{{ asset "qrcode/qrious2.min.js" }}"></script>
<script src="{{ asset "uri/URI.min.js" }}"></script>
<script src="{{ asset "js/model/reality_targets.js" }}"></script>
<script src="{{ asset "js/model/inbound.js" }}"></script>
<script src="{{ asset "js/model/dbinbound.js" }}"></script>

web/html/settings/panel/subscription/subpage.html

<script src="{{ asset "moment/moment.min.js" }}"></script>
<script src="{{ asset "moment/moment-jalali.min.js" }}"></script>
<script src="{{ asset "vue/vue.min.js" }}"></script>
<script src="{{ asset "ant-design-vue/antd.min.js" }}"></script>
<script src="{{ asset "js/util/index.js" }}"></script>
<script src="{{ asset "qrcode/qrious2.min.js" }}"></script>
<script src="{{ asset "js/subscription.js" }}"></script>

web/html/settings.html

<script src="{{ asset "qrcode/qrious2.min.js" }}"></script>
<script src="{{ asset "otpauth/otpauth.umd.min.js" }}"></script>
<script src="{{ asset "js/model/setting.js" }}"></script>

web/html/xray.html

<link rel="stylesheet" href="{{ asset "codemirror/codemirror.min.css" }}">
<link rel="stylesheet" href="{{ asset "codemirror/fold/foldgutter.css" }}">
<link rel="stylesheet" href="{{ asset "codemirror/xq.min.css" }}">
<link rel="stylesheet" href="{{ asset "codemirror/lint/lint.css" }}">
<script src="{{ asset "js/model/outbound.js" }}"></script>
<script src="{{ asset "codemirror/codemirror.min.js" }}"></script>
<script src="{{ asset "codemirror/javascript.js" }}"></script>
<script src="{{ asset "codemirror/jshint.js" }}"></script>
<script src="{{ asset "codemirror/jsonlint.js" }}"></script>
<script src="{{ asset "codemirror/lint/lint.js" }}"></script>
<script src="{{ asset "codemirror/lint/javascript-lint.js" }}"></script>
<script src="{{ asset "codemirror/hint/javascript-hint.js" }}"></script>
<script src="{{ asset "codemirror/fold/foldcode.js" }}"></script>
<script src="{{ asset "codemirror/fold/foldgutter.js" }}"></script>
<script src="{{ asset "codemirror/fold/brace-fold.js" }}"></script>
  • Step 5: Verify all fingerprintable asset references use the helper

Run: rg -n '{{ \\.base_path }}assets|\\?{{ \\.cur_ver }}' web/html

Expected: no remaining static asset references; route strings like {{ .base_path }}panel/ may remain.

  • Step 6: Commit the template updates
git add web/html/common/page.html web/html/component/aPersianDatepicker.html web/html/inbounds.html web/html/settings/panel/subscription/subpage.html web/html/settings.html web/html/xray.html web/asset_manifest_test.go
git commit -m "refactor: resolve template assets through manifest helper"

Task 5: Integrate Build Workflow and Verify End-to-End Behavior

Files:

  • Modify: Dockerfile

  • Modify: README.md

  • Test: web/web.go

  • Step 1: Add a build-step verification test for cache policy

Add this test to web/asset_manifest_test.go:

func TestAssetCacheControlForLogicalPathIsShortLived(t *testing.T) {
	got := assetCacheControl("js/websocket.js")
	want := "public, max-age=300"
	if got != want {
		t.Fatalf("expected %q, got %q", want, got)
	}
}
  • Step 2: Run the cache-policy tests

Run: go test ./web -run 'TestFingerprintCacheHeader|TestAssetCacheControlForLogicalPathIsShortLived' -count=1

Expected: PASS

  • Step 3: Update the builder image to generate assets before go build

In Dockerfile, insert the generator run before the binary build:

COPY . .

ENV CGO_ENABLED=1
ENV CGO_CFLAGS="-D_LARGEFILE64_SOURCE"
RUN go run ./cmd/genassets
RUN go build -ldflags "-w -s" -o build/x-ui main.go
  • Step 4: Document the direct-build workflow

Add a section to README.md like this:

## Building from source

Generate fingerprinted frontend assets before compiling:

- `go run ./cmd/genassets`
- `go build -ldflags "-w -s" -o build/x-ui main.go`

Production builds embed files from `web/public/assets` and `web/public/assets-manifest.json`.
  • Step 5: Run end-to-end verification

Run:

go run ./cmd/genassets
go test ./web/assetsgen -count=1
go test ./web -run 'TestAssetResolver|TestFingerprintCacheHeader|TestAssetCacheControlForLogicalPathIsShortLived' -count=1
go build ./...

Expected:

  • generator command succeeds

  • asset generation tests pass

  • web asset helper tests pass

  • repository builds successfully

  • Step 6: Commit the build integration

git add Dockerfile README.md web/asset_manifest_test.go
git commit -m "build: generate fingerprinted assets before compile"

Self-Review

Spec Coverage

  • Build-time generator and manifest: covered by Tasks 1 and 2.
  • Production embed of generated assets: covered by Task 3.
  • Manifest-based template helper: covered by Tasks 3 and 4.
  • Immutable asset caching and HTML revalidation: covered by Tasks 3 and 5.
  • Build and release documentation updates: covered by Task 5.

No spec gaps remain.

Placeholder Scan

  • No TODO, TBD, or deferred implementation markers remain.
  • Each code-changing step includes concrete code or exact replacement snippets.
  • Each verification step includes an explicit command and expected result.

Type Consistency

  • assetManifest, assetResolver, loadAssetManifest, and assetCacheControl are introduced in Task 3 and used consistently later.
  • Generator APIs use assetsgen.Options, assetsgen.Generate, and assetsgen.WriteManifest consistently across Tasks 1, 2, and 5.