支持 Minify AutoReversion 等

This commit is contained in:
AI Engineer 2026-05-07 21:11:06 +08:00
parent 951a9a7397
commit 308def589d
4 changed files with 315 additions and 9 deletions

14
go.mod
View File

@ -16,9 +16,10 @@ require (
github.com/ssgo/httpclient v1.7.9 github.com/ssgo/httpclient v1.7.9
github.com/ssgo/log v1.7.11 github.com/ssgo/log v1.7.11
github.com/ssgo/redis v1.7.8 github.com/ssgo/redis v1.7.8
github.com/ssgo/s v1.7.26 github.com/ssgo/s v1.7.30
github.com/ssgo/standard v1.7.8 github.com/ssgo/standard v1.7.8
github.com/ssgo/u v1.7.24 github.com/ssgo/u v1.7.24
github.com/tdewolff/minify/v2 v2.24.11
) )
require ( require (
@ -32,15 +33,16 @@ require (
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/gomodule/redigo v1.9.3 // indirect github.com/gomodule/redigo v1.9.3 // indirect
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 // indirect
github.com/obscuren/ecies v0.0.0-20150213224233-7c0f4a9b18d9 // indirect github.com/obscuren/ecies v0.0.0-20150213224233-7c0f4a9b18d9 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/shirou/gopsutil/v3 v3.24.5 // indirect github.com/shirou/gopsutil/v3 v3.24.5 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/shoenig/go-m1cpu v0.2.1 // indirect
github.com/ssgo/tool v0.4.29 // indirect github.com/ssgo/tool v0.4.29 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect github.com/tdewolff/parse/v2 v2.8.11 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/ysmood/fetchup v0.2.3 // indirect github.com/ysmood/fetchup v0.2.3 // indirect
github.com/ysmood/goob v0.4.0 // indirect github.com/ysmood/goob v0.4.0 // indirect
github.com/ysmood/got v0.40.0 // indirect github.com/ysmood/got v0.40.0 // indirect
@ -53,3 +55,5 @@ require (
golang.org/x/text v0.35.0 // indirect golang.org/x/text v0.35.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
replace github.com/ssgo/s => ../../ssgo/s

View File

@ -6,7 +6,9 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"math"
"os" "os"
"path"
"path/filepath" "path/filepath"
"reflect" "reflect"
"regexp" "regexp"
@ -77,6 +79,9 @@ type Config struct {
LimiterRedis string LimiterRedis string
HotLoad int HotLoad int
TplSafePaths []string TplSafePaths []string
AutoReversion bool
AutoMinify bool
AutoTranslate bool
Proxy map[string]string Proxy map[string]string
Rewrite map[string]string Rewrite map[string]string
@ -89,6 +94,19 @@ var onStop goja.Callable
var limiters = map[string]*s.Limiter{} var limiters = map[string]*s.Limiter{}
var configed = false var configed = false
var strTplRegexp = regexp.MustCompile(`%(\w+)%`)
func MakeByVar(text string, vars map[string]any) string {
text = strTplRegexp.ReplaceAllStringFunc(text, func(match string) string {
key := match[1 : len(match)-1]
if value, ok := vars[key]; ok {
return u.String(value)
}
return match
})
return text
}
func initConfig(opt *gojs.Obj, logger *log.Logger, vm *goja.Runtime) { func initConfig(opt *gojs.Obj, logger *log.Logger, vm *goja.Runtime) {
configed = true configed = true
s.InitConfig() s.InitConfig()
@ -96,7 +114,7 @@ func initConfig(opt *gojs.Obj, logger *log.Logger, vm *goja.Runtime) {
s.SetWorkPath(u.String(startPath)) s.SetWorkPath(u.String(startPath))
} }
// 处理配置 // 处理配置
serviceConfig = Config{"Session", "Device", "Client", "id", "name", "", 3600, "auth failed", "verify failed", "too many requests", nil, "", 0, []string{}, map[string]string{}, map[string]string{}, map[string]string{}} serviceConfig = Config{"Session", "Device", "Client", "id", "name", "", 3600, "auth failed", "verify failed", "too many requests", nil, "", 0, []string{}, false, false, false, map[string]string{}, map[string]string{}, map[string]string{}}
if errs := config.LoadConfig("service", &serviceConfig); len(errs) > 0 { if errs := config.LoadConfig("service", &serviceConfig); len(errs) > 0 {
// panic(vm.NewGoError(errs[0])) // panic(vm.NewGoError(errs[0]))
vm.SetData("_lastError", errs[0]) vm.SetData("_lastError", errs[0])
@ -104,7 +122,6 @@ func initConfig(opt *gojs.Obj, logger *log.Logger, vm *goja.Runtime) {
return return
} }
config.LoadConfig("service", &discover.Config) config.LoadConfig("service", &discover.Config)
// var auth goja.Callable
if opt != nil { if opt != nil {
u.Convert(opt.O.Export(), &serviceConfig) u.Convert(opt.O.Export(), &serviceConfig)
u.Convert(opt.O.Export(), &s.Config) u.Convert(opt.O.Export(), &s.Config)
@ -247,8 +264,13 @@ func initConfig(opt *gojs.Obj, logger *log.Logger, vm *goja.Runtime) {
} }
}, nil, nil) }, nil, nil)
} }
// 处理多语言
// LoadLanguage("language.yml")
} }
var resourceRegex = regexp.MustCompile(`(?i)(src|href|data|\w+)(\s*[=:]\s*)(["']?)([^"'\s>]+\.(js|css|json|svg|\w+))([^"'\s>]*)(["']?)`)
func init() { func init() {
s.Config.KeepKeyCase = true s.Config.KeepKeyCase = true
s.DontStartLogAuto = true s.DontStartLogAuto = true
@ -283,7 +305,8 @@ func init() {
} }
// 处理Watch // 处理Watch
if inWatch := vm.GetData("inWatch"); inWatch != nil && inWatch.(bool) { inWatch := u.Bool(vm.GetData("inWatch"))
if inWatch {
onWatchConn := map[string]*websocket.Conn{} onWatchConn := map[string]*websocket.Conn{}
onWatchLock := sync.Mutex{} onWatchLock := sync.Mutex{}
vm.SetData("onWatch", func(filename string) { vm.SetData("onWatch", func(filename string) {
@ -308,7 +331,7 @@ func init() {
s.SetOutFilter(func(in map[string]any, request *s.Request, response *s.Response, out any, logger *log.Logger) (newOut any, isOver bool) { s.SetOutFilter(func(in map[string]any, request *s.Request, response *s.Response, out any, logger *log.Logger) (newOut any, isOver bool) {
contentType := response.Header().Get("Content-Type") contentType := response.Header().Get("Content-Type")
outStr := u.String(out) outStr := u.String(out)
if strings.HasPrefix(contentType, "text/html") || (contentType == "" && strings.Contains(outStr, "<html")) { if strings.HasPrefix(contentType, "text/html") || (contentType == "" && strings.Contains(outStr[0:int(math.Min(float64(len(outStr)), 100))], "<html")) {
if strings.Contains(outStr, "let _watchWS = null") { if strings.Contains(outStr, "let _watchWS = null") {
return nil, false return nil, false
} }
@ -355,6 +378,166 @@ func init() {
}) })
} }
if inWatch {
// watch模式下不自动压缩方便调试定位脚本
serviceConfig.AutoMinify = false
}
// 处理自动版本号和自动压缩非watch模式
if serviceConfig.AutoReversion || serviceConfig.AutoMinify || serviceConfig.AutoTranslate {
s.SetOutFilter(func(in map[string]any, request *s.Request, response *s.Response, out any, logger *log.Logger) (newOut any, isOver bool) {
contentType := response.Header().Get("Content-Type")
outStr := u.String(out)
if strings.HasPrefix(contentType, "text/html") || (contentType == "" && strings.Contains(outStr[0:int(math.Min(float64(len(outStr)), 100))], "<html")) {
outChanged := false
newOutStr := resourceRegex.ReplaceAllStringFunc(outStr, func(match string) string {
subs := resourceRegex.FindStringSubmatch(match)
attr := subs[1] // src/href...
eq := subs[2]
quote1 := subs[3]
webPath := subs[4] // /res/app.js
ext := "." + subs[5] // .js
suffix := subs[6] // ?v=123456
quote2 := subs[7]
if strings.Contains(webPath, "://") || strings.HasPrefix(webPath, "//") || strings.HasPrefix(webPath, "data:") {
return match
}
if !strings.HasPrefix(webPath, "/") {
if strings.HasSuffix(request.URL.Path, "/") {
webPath = request.URL.Path + webPath
} else {
webPath = path.Join(path.Dir(request.URL.Path), webPath)
}
}
// 获取磁盘物理路径
localPath := s.GetStaticPath(webPath, request.Host)
if localPath == "" {
return match
}
changed := false
// 自动查找翻译文件
if serviceConfig.AutoTranslate {
language := ""
if cookieLanguage, err := request.Cookie("language"); err == nil {
language = cookieLanguage.Value
} else if uaLanguage := request.Header.Get("Accept-Language"); uaLanguage != "" {
language = uaLanguage
} else {
language = "en-US"
}
translationPath := strings.TrimSuffix(localPath, ext) + "." + language + ext
if u.FileExists(translationPath) {
localPath = translationPath
webPath = strings.TrimSuffix(webPath, ext) + "." + language + ext
changed = true
} else {
language2 := strings.Split(language, "-")[0]
translationPath = strings.TrimSuffix(localPath, ext) + "." + language2 + ext
if u.FileExists(translationPath) {
localPath = translationPath
webPath = strings.TrimSuffix(webPath, ext) + "." + language2 + ext
changed = true
}
}
}
if serviceConfig.AutoMinify && !strings.Contains(webPath, ".min.") && !strings.Contains(webPath, "-min.") && (ext == ".js" || ext == ".css" || ext == ".svg" || ext == ".json") {
localMinPath := strings.TrimSuffix(localPath, ext) + ".min" + ext
if err := Minify(localPath, localMinPath); err == nil {
webPath = strings.TrimSuffix(webPath, ext) + ".min" + ext
changed = true
}
}
if serviceConfig.AutoReversion {
if srcStat := u.GetFileInfo(localPath); srcStat != nil {
ver := uint64(srcStat.ModTime.Unix())
verStr := string(u.HashInt(u.EncodeInt(ver)))
if suffix == "" {
suffix = fmt.Sprintf("?v=%s", verStr)
} else if suffix[0] == '?' {
suffix = fmt.Sprintf("?v=%s&%s", verStr, suffix[1:])
} else {
suffix = fmt.Sprintf("?v=%s%s", verStr, suffix)
}
changed = true
}
}
if changed {
// 拼接新路径,保留原有属性名
outChanged = true
return fmt.Sprintf(`%s%s%s%s%s%s`, attr, eq, quote1, webPath, suffix, quote2)
}
return match
})
if outChanged {
return newOutStr, true
}
}
return nil, false
})
}
// 处理html自动压缩非watch模式
if (inWatch && serviceConfig.AutoReversion) || serviceConfig.AutoMinify || serviceConfig.AutoTranslate {
s.SetStaticRewriter(func(filename string, request *s.Request, response *s.Response, logger *log.Logger) string {
changed := false
isHTML := strings.HasSuffix(filename, ".html") || strings.HasSuffix(filename, ".htm")
if (inWatch && serviceConfig.AutoReversion) && isHTML {
// 开发模式下自动处理应用版本时移除If-Modified-Since头禁用html缓存
request.Header.Del("If-Modified-Since")
}
if serviceConfig.AutoMinify || serviceConfig.AutoTranslate {
if isHTML && !strings.Contains(filename, ".min.") && !strings.Contains(filename, "-min.") {
ext := filepath.Ext(filename)
if serviceConfig.AutoTranslate {
language := ""
if cookieLanguage, err := request.Cookie("language"); err == nil {
language = cookieLanguage.Value
} else if uaLanguage := request.Header.Get("Accept-Language"); uaLanguage != "" {
language = uaLanguage
} else {
language = "en-US"
}
translationPath := strings.TrimSuffix(filename, ext) + "." + language + ext
if u.FileExists(translationPath) {
filename = translationPath
changed = true
} else {
language2 := strings.Split(language, "-")[0]
translationPath = strings.TrimSuffix(filename, ext) + "." + language2 + ext
if u.FileExists(translationPath) {
filename = translationPath
changed = true
}
}
}
if serviceConfig.AutoMinify {
minFilename := strings.TrimSuffix(filename, ext) + ".min" + ext
if err := Minify(filename, minFilename); err == nil {
filename = minFilename
changed = true
}
}
}
}
if changed {
return filename
}
return ""
})
}
// 启动服务 // 启动服务
server = s.AsyncStart() server = s.AsyncStart()
waitChan = make(chan bool, 1) waitChan = make(chan bool, 1)
@ -716,6 +899,19 @@ func init() {
// } // }
// return nil // return nil
// }, // },
"minify": func(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value {
args := gojs.MakeArgs(&argsIn, vm).Check(2)
if err := Minify(args.Str(0), args.Str(1)); err == nil {
} else {
vm.SetData("_lastError", err.Error())
gojs.GetLogger(vm).Error(err.Error())
}
return nil
},
"getStaticPath": func(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value {
args := gojs.MakeArgs(&argsIn, vm).Check(2)
return vm.ToValue(s.GetStaticPath(args.Str(0), args.Str(1)))
},
"setTplReplaces": func(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value { "setTplReplaces": func(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value {
args := gojs.MakeArgs(&argsIn, vm).Check(1) args := gojs.MakeArgs(&argsIn, vm).Check(1)
replaces := args.Map(0) replaces := args.Map(0)
@ -790,6 +986,7 @@ func init() {
} }
} }
} }
if len(fnList) > 0 { if len(fnList) > 0 {
tpl = tpl.Funcs(fnList) tpl = tpl.Funcs(fnList)
} }

View File

@ -25,6 +25,8 @@ export default {
// setTplFunc, // setTplFunc,
setTplReplaces, setTplReplaces,
tpl, tpl,
minify,
getStaticPath,
} }
function config(config?: Config): void { } function config(config?: Config): void { }
@ -56,6 +58,8 @@ function id(size?: number): string { return '' }
// function setTplFunc(fnList: Object): void { } // function setTplFunc(fnList: Object): void { }
function setTplReplaces(replaces: Object): void { } function setTplReplaces(replaces: Object): void { }
function tpl(file: string, data: Object, fnList?: Object): string { return '' } function tpl(file: string, data: Object, fnList?: Object): string { return '' }
function minify(from: string, to: string): string { return '' }
function getStaticPath(requestPath: string, host?: string): string { return '' }
export interface Config { export interface Config {
// github.com/ssgo/s 的配置参数 // github.com/ssgo/s 的配置参数
@ -122,6 +126,8 @@ export interface Config {
limiters: Map<string, LimiterConfig> // 限流器配置from 为数据来源例如ip、user、device、header.User-Agent、in.phone 等in表示从请求参数中获取time为时间间隔单位mstimes为 时间间隔内允许访问的次数 limiters: Map<string, LimiterConfig> // 限流器配置from 为数据来源例如ip、user、device、header.User-Agent、in.phone 等in表示从请求参数中获取time为时间间隔单位mstimes为 时间间隔内允许访问的次数
hotLoad: number // 热加载配置单位s默认值00表示不检测热加载 hotLoad: number // 热加载配置单位s默认值00表示不检测热加载
tplSafePaths: string[] // 模板文件的安全路径,默认不开启安全检查 tplSafePaths: string[] // 模板文件的安全路径,默认不开启安全检查
autoReversion: boolean // 是否自动为html中引用的本地.js .css .json .svg 等文件添加版本号,默认不开启
autoMinify: boolean // 是否自动压缩html中引用的本地.js .css .json .svg 等文件,默认不开启
// gateway 的配置参数 // gateway 的配置参数
proxy: Map<string, string> // 代理配置key为[host][path]value为代理的目标应用或URL proxy: Map<string, string> // 代理配置key为[host][path]value为代理的目标应用或URL

99
web.go Normal file
View File

@ -0,0 +1,99 @@
package service
import (
"errors"
"os"
"path/filepath"
"regexp"
"strings"
"syscall"
"github.com/ssgo/u"
"github.com/tdewolff/minify/v2"
"github.com/tdewolff/minify/v2/css"
"github.com/tdewolff/minify/v2/html"
"github.com/tdewolff/minify/v2/js"
"github.com/tdewolff/minify/v2/json"
"github.com/tdewolff/minify/v2/svg"
"github.com/tdewolff/minify/v2/xml"
)
var m *minify.M
func init() {
m = minify.New()
m.AddFunc("text/html", html.Minify)
m.AddFunc("text/css", css.Minify)
m.AddFunc("image/svg+xml", svg.Minify)
m.AddFuncRegexp(regexp.MustCompile("^(application|text)/(x-)?(java|ecma)script$"), js.Minify)
m.AddFuncRegexp(regexp.MustCompile("[/+]json$"), json.Minify)
m.AddFuncRegexp(regexp.MustCompile("[/+]xml$"), xml.Minify)
}
var reJSBacktickContent = regexp.MustCompile("(?s)`([^`]*)`")
var reHTMLBetweenTags = regexp.MustCompile(`>\s+<`)
func Minify(from, to string) error {
srcStat := u.GetFileInfo(from)
if srcStat == nil {
return errors.New("file not found: " + from)
}
dstStat := u.GetFileInfo(to)
if dstStat != nil && !srcStat.ModTime.After(dstStat.ModTime) {
return nil
}
tmpTo := filepath.Join(filepath.Dir(to), "."+filepath.Base(to))
dst, err := os.OpenFile(tmpTo, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
return err
}
defer os.Remove(tmpTo)
defer dst.Close()
if syscall.Flock(int(dst.Fd()), syscall.LOCK_EX) == nil {
defer syscall.Flock(int(dst.Fd()), syscall.LOCK_UN)
}
dstStat = u.GetFileInfo(to)
if dstStat != nil && !srcStat.ModTime.After(dstStat.ModTime) {
return nil
}
input := u.ReadFileN(from)
processedInput := reJSBacktickContent.ReplaceAllStringFunc(input, func(s string) string {
return "`" + reHTMLBetweenTags.ReplaceAllString(strings.TrimSpace(s[1:len(s)-1]), "><") + "`"
})
minifiedOutput, err := m.String(getMimeType(from), processedInput)
if err != nil {
return err
}
if _, err = dst.WriteString(minifiedOutput); err != nil {
return err
}
if err = dst.Close(); err == nil {
err = os.Rename(tmpTo, to)
}
return err
}
func getMimeType(filename string) string {
switch filepath.Ext(filename) {
case ".html", ".htm":
return "text/html"
case ".css":
return "text/css"
case ".js":
return "application/javascript"
case ".json":
return "application/json"
case ".svg":
return "image/svg+xml"
case ".xml":
return "text/xml"
default:
return "text/plain"
}
}