fix(frontend): resolve Vite assets by manifest source path (#37836)

In dev mode `/api/swagger` returned HTTP 500 (`Failed to locate local
path for managed asset URI: css/swagger.css`): the backend synthesised
asset keys from the Vite entry name instead of reading the manifest,
which only worked by coincidence and broke once a source file name
diverged from its entry name.

This keys the manifest by its source path (e.g. `web_src/js/index.ts`)
and resolves entries directly — hashed `file` in prod, dev-server source
in dev. A new `AssetCSSLinks` helper renders a JS entry's stylesheet
`<link>` tags from the manifest (the entry's CSS plus the CSS of its
statically-imported chunks).

Fixes: https://github.com/go-gitea/gitea/issues/37830
Fixes: https://github.com/go-gitea/gitea/pull/37832
Fixes: https://github.com/go-gitea/gitea/pull/37876
Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: prakhar0x01 <prakharporwal2004@gmail.com>
Co-authored-by: Nicolas <bircni@icloud.com>
Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
silverwind
2026-05-28 08:14:52 +02:00
committed by GitHub
parent db04bcb31a
commit 52fef74291
15 changed files with 122 additions and 128 deletions

View File

@@ -90,6 +90,6 @@ func (p *frontendRenderer) Render(ctx *markup.RenderContext, input io.Reader, ou
</html>`,
p.name, ctx.RenderOptions.RelativePath,
contentEncoding, contentString,
public.AssetURI("js/external-render-frontend.js"))
public.AssetURI("web_src/js/external-render-frontend.ts"))
return err
}

View File

@@ -243,7 +243,7 @@ func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader,
return RenderIFrame(ctx, &extOpts, output)
}
// else: this is a standalone page, fallthrough to the real rendering, and add extra JS/CSS
extraScriptSrc := public.AssetURI("js/external-render-helper.js")
extraScriptSrc := public.AssetURI("web_src/js/external-render-helper.ts")
extraLinkHref := ctx.RenderOptions.StandalonePageOptions.CurrentWebTheme.PublicAssetURI()
// "<script>" must go before "<link>", to make Golang's http.DetectContentType() can still recognize the content as "text/html"
// DO NOT use "type=module", the script must run as early as possible, to set up the environment in the iframe

View File

@@ -4,8 +4,11 @@
package public
import (
"html"
"html/template"
"io"
"path"
"strings"
"sync"
"sync/atomic"
"time"
@@ -15,16 +18,17 @@ import (
"gitea.dev/modules/setting"
)
// https://vite.dev/guide/backend-integration
type manifestEntry struct {
File string `json:"file"`
Name string `json:"name"`
IsEntry bool `json:"isEntry"`
CSS []string `json:"css"`
Imports []string `json:"imports"`
}
type manifestDataStruct struct {
paths map[string]string // unhashed path -> hashed path
names map[string]string // hashed path -> entry name
entries map[string]*manifestEntry // source path -> entry
names map[string]string // content-hashed output file -> entry name
modTime int64
checkTime time.Time
}
@@ -36,35 +40,16 @@ var (
const manifestPath = "assets/.vite/manifest.json"
func parseManifest(data []byte) (map[string]string, map[string]string) {
var manifest map[string]manifestEntry
if err := json.Unmarshal(data, &manifest); err != nil {
func parseManifest(data []byte) (entries map[string]*manifestEntry, names map[string]string) {
if err := json.Unmarshal(data, &entries); err != nil {
log.Error("Failed to parse frontend manifest: %v", err)
return nil, nil
}
paths := make(map[string]string)
names := make(map[string]string)
for _, entry := range manifest {
if !entry.IsEntry || entry.Name == "" {
continue
}
// Build unhashed key from file path: "js/index.js", "css/theme-gitea-dark.css"
dir := path.Dir(entry.File)
ext := path.Ext(entry.File)
key := dir + "/" + entry.Name + ext
paths[key] = entry.File
names = make(map[string]string, len(entries))
for _, entry := range entries {
names[entry.File] = entry.Name
// Map associated CSS files, e.g. "css/index.css" -> "css/index.B3zrQPqD.css"
// FIXME: INCORRECT-VITE-MANIFEST-PARSER: the logic is wrong, Vite manifest doesn't work this way
// It just happens to be correct for the current modules dependencies
for _, css := range entry.CSS {
cssKey := path.Dir(css) + "/" + entry.Name + path.Ext(css)
paths[cssKey] = css
names[css] = entry.Name
}
}
return paths, names
return entries, names
}
func reloadManifest(existingData *manifestDataStruct) *manifestDataStruct {
@@ -102,9 +87,9 @@ func reloadManifest(existingData *manifestDataStruct) *manifestDataStruct {
}
func storeManifestFromBytes(manifestContent []byte, modTime int64, checkTime time.Time) *manifestDataStruct {
paths, names := parseManifest(manifestContent)
entries, names := parseManifest(manifestContent)
data := &manifestDataStruct{
paths: paths,
entries: entries,
names: names,
modTime: modTime,
checkTime: checkTime,
@@ -127,33 +112,66 @@ func getManifestData() *manifestDataStruct {
return data
}
// AssetURI returns the URI for a frontend asset.
// It may return a relative path or a full URL depending on the StaticURLPrefix setting.
// In Vite dev mode, known entry points are mapped to their source paths
// so the reverse proxy serves them from the Vite dev server.
// In production, it resolves the content-hashed path from the manifest.
func AssetURI(originPath string) string {
// devAssetURL returns a source file's Vite dev server URL, panicking in dev/testing if it's absent.
func devAssetURL(src string) string {
if url := viteDevSourceURL(src); url != "" {
return url
}
setting.PanicInDevOrTesting("Failed to locate source file for asset: %s", src)
return ""
}
// AssetURI resolves a frontend asset by its source path (the Vite manifest key, e.g.
// "web_src/js/index.ts"). Dev mode serves the source file; production resolves the hashed output.
func AssetURI(srcPath string) string {
if IsViteDevMode() {
if src := viteDevSourceURL(originPath); src != "" {
return src
}
// it should be caused by incorrect vite config
setting.PanicInDevOrTesting("Failed to locate local path for managed asset URI: %s", originPath)
return devAssetURL(srcPath)
}
if entry := getManifestData().entries[srcPath]; entry != nil {
return setting.StaticURLPrefix + "/assets/" + entry.File
}
// The only expected manifest miss is a user's custom theme CSS, served as a static asset
// under "/assets/css/". Anything else is a misconfigured or missing entry.
if path.Ext(srcPath) == ".css" {
return setting.StaticURLPrefix + "/assets/css/" + path.Base(srcPath)
}
log.Error("asset not found in frontend manifest: %s", srcPath)
return setting.StaticURLPrefix + "/assets/" + path.Base(srcPath)
}
// Try to resolve an unhashed asset path (origin path) to its content-hashed path from the frontend manifest.
// Example: "js/index.js" -> "js/index.C6Z2MRVQ.js"
data := getManifestData()
assetPath := data.paths[originPath]
if assetPath == "" {
// it should be caused by either: "incorrect vite config" or "user's custom theme"
assetPath = originPath
if !setting.IsProd {
log.Warn("Failed to find managed asset URI for origin path: %s", originPath)
// AssetCSSLinks renders the <link> tags for a JS entry's stylesheets: the entry's CSS plus the CSS
// of every statically-imported chunk. Dev links devStylesheetSrc and lets the JS module inject the rest.
func AssetCSSLinks(jsEntrySrc, devStylesheetSrc string) template.HTML {
var b strings.Builder
for _, href := range entryStyleURLs(jsEntrySrc, devStylesheetSrc) {
b.WriteString(`<link rel="stylesheet" href="` + html.EscapeString(href) + `">`)
}
return template.HTML(b.String())
}
func entryStyleURLs(jsEntrySrc, devStylesheetSrc string) []string {
if IsViteDevMode() {
return []string{devAssetURL(devStylesheetSrc)}
}
entries := getManifestData().entries
var urls []string
seen := make(map[string]bool)
var walk func(key string)
walk = func(key string) {
entry := entries[key]
if entry == nil || seen[key] {
return
}
seen[key] = true
for _, css := range entry.CSS {
urls = append(urls, setting.StaticURLPrefix+"/assets/"+css)
}
for _, imp := range entry.Imports {
walk(imp)
}
}
return setting.StaticURLPrefix + "/assets/" + assetPath
walk(jsEntrySrc)
return urls
}
// AssetNameFromHashedPath returns the asset entry name for a given hashed asset path.

View File

@@ -4,6 +4,7 @@
package public
import (
"html/template"
"testing"
"time"
@@ -22,59 +23,54 @@ func TestViteManifest(t *testing.T) {
"name": "index",
"src": "web_src/js/index.ts",
"isEntry": true,
"css": ["css/index.B3zrQPqD.css"]
"imports": ["_shared.AaAaAaAa.js"],
"css": ["css/index.B3zrQPqD.css", "css/index-extra.CcCcCcCc.css"]
},
"_shared.AaAaAaAa.js": {
"file": "js/shared.AaAaAaAa.js",
"name": "shared",
"css": ["css/shared.BbBbBbBb.css"]
},
"web_src/css/themes/theme-gitea-dark.css": {
"file": "css/theme-gitea-dark.CyAaQnn5.css",
"name": "theme-gitea-dark",
"src": "web_src/css/themes/theme-gitea-dark.css",
"isEntry": true
},
"web_src/js/features/eventsource.sharedworker.ts": {
"file": "js/eventsource.sharedworker.Dug1twio.js",
"name": "eventsource.sharedworker",
"src": "web_src/js/features/eventsource.sharedworker.ts",
"isEntry": true
},
"_chunk.js": {
"file": "js/chunk.abc123.js",
"name": "chunk"
}
}`
t.Run("EmptyManifest", func(t *testing.T) {
storeManifestFromBytes([]byte(``), 0, time.Now())
assert.Equal(t, "/assets/js/index.js", AssetURI("js/index.js"))
assert.Equal(t, "/assets/css/theme-gitea-dark.css", AssetURI("css/theme-gitea-dark.css"))
assert.Equal(t, "", AssetNameFromHashedPath("css/no-such-file.css"))
// not in manifest -> custom theme fallback
assert.Equal(t, "/assets/css/theme-gitea-dark.css", AssetURI("web_src/css/themes/theme-gitea-dark.css"))
assert.Empty(t, entryStyleURLs("web_src/js/index.ts", "web_src/css/index.css"))
assert.Empty(t, AssetNameFromHashedPath("css/no-such-file.css"))
})
t.Run("ParseManifest", func(t *testing.T) {
storeManifestFromBytes([]byte(testManifest), 0, time.Now())
paths, names := manifestData.Load().paths, manifestData.Load().names
// JS entries
assert.Equal(t, "js/index.C6Z2MRVQ.js", paths["js/index.js"])
assert.Equal(t, "js/eventsource.sharedworker.Dug1twio.js", paths["js/eventsource.sharedworker.js"])
// assets are addressed by their source path (the manifest key)
assert.Equal(t, "/assets/js/index.C6Z2MRVQ.js", AssetURI("web_src/js/index.ts"))
assert.Equal(t, "/assets/css/theme-gitea-dark.CyAaQnn5.css", AssetURI("web_src/css/themes/theme-gitea-dark.css"))
// Associated CSS from JS entries
assert.Equal(t, "css/index.B3zrQPqD.css", paths["css/index.css"])
// custom theme not in the manifest falls back to the static asset location
assert.Equal(t, "/assets/css/theme-custom.css", AssetURI("web_src/css/themes/theme-custom.css"))
// CSS-only entries
assert.Equal(t, "css/theme-gitea-dark.CyAaQnn5.css", paths["css/theme-gitea-dark.css"])
// a JS entry's stylesheets: all of the entry's own CSS plus the CSS of statically-imported chunks
assert.Equal(t, []string{
"/assets/css/index.B3zrQPqD.css",
"/assets/css/index-extra.CcCcCcCc.css",
"/assets/css/shared.BbBbBbBb.css",
}, entryStyleURLs("web_src/js/index.ts", "web_src/css/index.css"))
assert.Equal(t, template.HTML(
`<link rel="stylesheet" href="/assets/css/index.B3zrQPqD.css">`+
`<link rel="stylesheet" href="/assets/css/index-extra.CcCcCcCc.css">`+
`<link rel="stylesheet" href="/assets/css/shared.BbBbBbBb.css">`,
), AssetCSSLinks("web_src/js/index.ts", "web_src/css/index.css"))
// Non-entry chunks should not be included
assert.Empty(t, paths["js/chunk.js"])
// Names: hashed path -> entry name
assert.Equal(t, "index", names["js/index.C6Z2MRVQ.js"])
assert.Equal(t, "index", names["css/index.B3zrQPqD.css"])
assert.Equal(t, "theme-gitea-dark", names["css/theme-gitea-dark.CyAaQnn5.css"])
assert.Equal(t, "eventsource.sharedworker", names["js/eventsource.sharedworker.Dug1twio.js"])
// Test Asset related functions
assert.Equal(t, "/assets/js/index.C6Z2MRVQ.js", AssetURI("js/index.js"))
assert.Equal(t, "/assets/css/theme-gitea-dark.CyAaQnn5.css", AssetURI("css/theme-gitea-dark.css"))
// hashed output file -> entry name
assert.Equal(t, "theme-gitea-dark", AssetNameFromHashedPath("css/theme-gitea-dark.CyAaQnn5.css"))
assert.Empty(t, AssetNameFromHashedPath("css/no-such-file.css"))
})
}

View File

@@ -140,31 +140,13 @@ func IsViteDevMode() bool {
return isDev
}
func detectWebSrcPath(webSrcPath string) string {
localPath := util.FilePathJoinAbs(setting.StaticRootPath, "web_src", webSrcPath)
if _, err := os.Stat(localPath); err == nil {
return setting.AppSubURL + "/web_src/" + webSrcPath
// viteDevSourceURL returns the dev server URL for a source file, or "" if it doesn't exist.
func viteDevSourceURL(srcPath string) string {
localPath := util.FilePathJoinAbs(setting.StaticRootPath, srcPath)
if _, err := os.Stat(localPath); err != nil {
return ""
}
return ""
}
func viteDevSourceURL(name string) string {
if strings.HasPrefix(name, "css/theme-") {
// Only redirect built-in themes to Vite source; custom themes are served from custom/public/assets/css/
themeFilePath := "css/themes/" + strings.TrimPrefix(name, "css/")
if srcPath := detectWebSrcPath(themeFilePath); srcPath != "" {
return srcPath
}
}
// try to map ".js" files to ".ts" files
pathPrefix, ok := strings.CutSuffix(name, ".js")
if ok {
if srcPath := detectWebSrcPath(pathPrefix + ".ts"); srcPath != "" {
return srcPath
}
}
// for all others that the names match
return detectWebSrcPath(name)
return setting.AppSubURL + "/" + srcPath
}
// isViteDevRequest returns true if the request should be proxied to the Vite dev server.

View File

@@ -67,7 +67,8 @@ func newFuncMapWebPage() template.FuncMap {
return strconv.FormatInt(time.Since(startTime).Nanoseconds()/1e6, 10) + "ms"
},
"AssetURI": public.AssetURI,
"AssetURI": public.AssetURI,
"AssetCSSLinks": public.AssetCSSLinks,
// -----------------------------------------------------------------
// setting

View File

@@ -45,7 +45,7 @@ type ThemeMetaInfo struct {
}
func (info *ThemeMetaInfo) PublicAssetURI() string {
return public.AssetURI("css/theme-" + url.PathEscape(info.InternalName) + ".css")
return public.AssetURI("web_src/css/themes/theme-" + url.PathEscape(info.InternalName) + ".css")
}
func (info *ThemeMetaInfo) GetDescription() string {

View File

@@ -9,7 +9,7 @@
</div>
{{template "custom/body_outer_post" .}}
{{template "base/footer_content" .}}
{{ctx.ScriptImport "js/index.js" "module"}}
{{ctx.ScriptImport "web_src/js/index.ts" "module"}}
{{template "custom/footer" .}}
<script nonce="{{ctx.CspScriptNonce}}" type="module">
if (!window.config?.frontendInited && window.config?.runModeIsProd) alert("Frontend is not initialized, check console errors or asset files.");

View File

@@ -16,7 +16,7 @@ If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly.
notificationSettings: {{NotificationSettings}}, {{/*a map provided by NewFuncMap in helper.go*/}}
enableTimeTracking: {{EnableTimetracking}},
mermaidMaxSourceCharacters: {{MermaidMaxSourceCharacters}},
sharedWorkerUri: '{{AssetURI "js/eventsource.sharedworker.js"}}',
sharedWorkerUri: '{{AssetURI "web_src/js/eventsource.sharedworker.ts"}}',
{{/* this global i18n object should only contain general texts. for specialized texts, it should be provided inside the related modules by: (1) API response (2) HTML data-attribute (3) PageData */}}
i18n: {
error_occurred: {{ctx.Locale.Tr "error.occurred"}},
@@ -31,4 +31,4 @@ If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly.
{{/* in case some pages don't render the pageData, we make sure it is an object to prevent null access */}}
window.config.pageData = window.config.pageData || {};
</script>
{{ctx.ScriptImport "js/iife.js"}}
{{ctx.ScriptImport "web_src/js/iife.ts"}}

View File

@@ -1,2 +1,2 @@
<link rel="stylesheet" href="{{AssetURI "css/index.css"}}">
{{AssetCSSLinks "web_src/js/index.ts" "web_src/css/index.css"}}
<link rel="stylesheet" href="{{ctx.CurrentWebTheme.PublicAssetURI}}">

View File

@@ -1,4 +1,4 @@
{{template "base/head" ctx.RootData}}
<link rel="stylesheet" href="{{AssetURI "css/devtest.css"}}">
<link rel="stylesheet" href="{{AssetURI "web_src/css/devtest.css"}}">
<div class="tw-hidden" data-global-init="initDevtestPage"></div>
<div class="ui container tw-mt-4">{{template "base/alert" ctx.RootData}}</div>

View File

@@ -4,14 +4,14 @@
{{ctx.HeadMetaContentSecurityPolicy}}
<title>Gitea API</title>
<link rel="stylesheet" href="{{ctx.CurrentWebTheme.PublicAssetURI}}">
{{/* HINT: SWAGGER-CSS-IMPORT: import swagger styles ahead to avoid UI flicker (e.g.: the swagger-back-link element) */}}
<link rel="stylesheet" href="{{AssetURI "css/swagger.css"}}">
{{/* HINT: SWAGGER-CSS-IMPORT: load swagger styles ahead to avoid flicker (e.g. the swagger-back-link) */}}
{{AssetCSSLinks "web_src/js/swagger.ts" "web_src/css/swagger-standalone.css"}}
</head>
<body>
{{/* TODO: add Help & Glossary to help users understand the API, and explain some concepts like "Owner" */}}
<a class="swagger-back-link" href="{{AppSubUrl}}/">{{svg "octicon-reply"}}{{ctx.Locale.Tr "return_to_gitea"}}</a>
<div id="swagger-ui" data-source="{{AppSubUrl}}/swagger.v1.json"></div>
<footer class="page-footer"></footer>
{{ctx.ScriptImport "js/swagger.js" "module"}}
{{ctx.ScriptImport "web_src/js/swagger.ts" "module"}}
</body>
</html>

View File

@@ -109,8 +109,8 @@ func TestExternalMarkupRenderer(t *testing.T) {
assert.Equal(t, "frame-src 'self'; sandbox allow-scripts allow-popups", respSub.Header().Get("Content-Security-Policy"))
// FIXME: actually here is a bug (legacy design problem), the "PostProcess" will escape "<script>" tag, but it indeed is the sanitizer's job
assert.Equal(t,
`<script nonce crossorigin src="`+public.AssetURI("js/external-render-helper.js")+`" id="gitea-external-render-helper" data-render-query-string=""></script>`+
`<link rel="stylesheet" href="`+public.AssetURI("css/theme-gitea-auto.css")+`">`+
`<script nonce crossorigin src="`+public.AssetURI("web_src/js/external-render-helper.ts")+`" id="gitea-external-render-helper" data-render-query-string=""></script>`+
`<link rel="stylesheet" href="`+public.AssetURI("web_src/css/themes/theme-gitea-auto.css")+`">`+
`<div><any attr="val">&lt;script&gt;&lt;/script&gt;</any></div>`,
respSub.Body.String(),
)
@@ -137,8 +137,8 @@ func TestExternalMarkupRenderer(t *testing.T) {
req := NewRequest(t, "GET", "/user2/repo1/render/branch/master/html.no-sanitizer?a=1%2f2")
respSub := MakeRequest(t, req, http.StatusOK)
assert.Equal(t,
`<script nonce crossorigin src="`+public.AssetURI("js/external-render-helper.js")+`" id="gitea-external-render-helper" data-render-query-string="a=1%2f2"></script>`+
`<link rel="stylesheet" href="`+public.AssetURI("css/theme-gitea-auto.css")+`">`+
`<script nonce crossorigin src="`+public.AssetURI("web_src/js/external-render-helper.ts")+`" id="gitea-external-render-helper" data-render-query-string="a=1%2f2"></script>`+
`<link rel="stylesheet" href="`+public.AssetURI("web_src/css/themes/theme-gitea-auto.css")+`">`+
`<script>foo("raw")</script>`,
respSub.Body.String(),
)

View File

@@ -267,7 +267,6 @@ export default defineConfig(commonViteOpts({
manifest: true,
rolldownOptions: {
input: {
// FIXME: INCORRECT-VITE-MANIFEST-PARSER: the "css importing" logic in backend is wrong
index: join(import.meta.dirname, 'web_src/js/index.ts'),
swagger: join(import.meta.dirname, 'web_src/js/swagger.ts'),
'external-render-frontend': join(import.meta.dirname, 'web_src/js/external-render-frontend.ts'),

View File

@@ -1,5 +1,3 @@
// FIXME: INCORRECT-VITE-MANIFEST-PARSER: it just happens to work for current dependencies
// If this module depends on another one and that one imports "swagger.css", then {{AssetURI "css/swagger.css"}} won't work
import '../css/swagger-standalone.css';
import {initSwaggerUI} from './render/swagger.ts';