fix: work-around for systems reporting 404 errors while loading frontend #1248
Massimo Melina committed
Jun 23, 2026 at 12:00 UTC
858d41e480b6829d664eef118b600c099f5b153e
5 files changed
+58
-33
src/comments.ts
+2
-2
@@ -102,11 +102,11 @@ async function readDescriptIon(path: string) {
102
}))
103
ret.delete('')
104
return ret
105
- }, 2000)
105
+ }, 2000).then(x => x.content)
106
}
107
108
descriptIonEncoding.sub(() => { // invalidate cache at encoding change
109
for (const k of parseFileCache.keys())
110
if (k.endsWith(DESCRIPT_ION) || k.endsWith(DESCRIPT_ION_ALT))
111
parseFileCache.delete(k)
112
-})
\ No newline at end of file
112
+})
src/serveFile.ts
+10
-10
@@ -74,12 +74,12 @@ const mimeCfg = defineConfig<Dict<string>, (name: string) => string | undefined>
74
// after this number of seconds, the browser should check the server to see if there's a newer version of the file
75
const cacheControlDiskFiles = defineConfig('cache_control_disk_files', 5)
76
77
-export async function serveFile(ctx: Koa.Context, source:string, mime?:string, content?: string | Buffer) {
78
- if (!source)
77
+export async function serveFile(ctx: Koa.Context, filePath:string, mime?:string, cached?: { stats: Stats, content: string | Buffer }) {
78
+ if (!filePath)
79
return
80
- mime ??= mimeCfg.compiled()(basename(source))
80
+ mime ??= mimeCfg.compiled()(basename(filePath))
81
if (mime === undefined || mime === MIME_AUTO)
82
- mime = mimetypes.lookup(source) || ''
82
+ mime = mimetypes.lookup(filePath) || ''
83
if (mime)
84
ctx.type = mime
85
if (ctx.method === 'OPTIONS') {
@@ -90,26 +90,26 @@ export async function serveFile(ctx: Koa.Context, source:string, mime?:string, c
90
if (ctx.method !== 'GET')
91
return ctx.status = HTTP_METHOD_NOT_ALLOWED
92
try {
93
- const stats = await promisify(stat)(source) // using fs's function instead of fs/promises, because only the former is supported by pkg
93
+ const stats = cached?.stats || await promisify(stat)(filePath) // using fs's function instead of fs/promises, because only the former is supported by pkg
94
if (!stats.isFile())
95
return ctx.status = HTTP_METHOD_NOT_ALLOWED
96
const t = stats.mtime.toUTCString()
97
ctx.set('Last-Modified', t)
98
- ctx.set('Etag', createHash('sha256').update(source).update(t).digest('hex'))
99
- ctx.state.fileSource = source
98
+ ctx.set('Etag', createHash('sha256').update(filePath).update(t).digest('hex'))
99
+ ctx.state.fileSource = filePath
100
ctx.state.fileStats = stats
101
ctx.status = HTTP_OK
102
if (ctx.fresh)
103
return ctx.status = HTTP_NOT_MODIFIED
104
- if (content !== undefined)
105
- return ctx.body = content
104
+ if (cached)
105
+ return ctx.body = cached.content
106
const cc = cacheControlDiskFiles.get()
107
if (_.isNumber(cc))
108
ctx.set('Cache-Control', `max-age=${cc}`)
109
const { size } = stats
110
const range = applyRange(ctx, size)
111
if (ctx.status >= 400) return // applyRange may have set an error
112
- ctx.body = createReadStream(source, range || undefined)
112
+ ctx.body = createReadStream(filePath, range || undefined)
113
if (ctx.state.vfsNode)
114
monitorAsDownload(ctx, size, range?.start)
115
}
src/serveGuiAndSharedFiles.ts
+1
-1
@@ -80,7 +80,7 @@ export const serveSharedFiles: Koa.Middleware = async (ctx, next) => {
80
return !folder.source || !isValidFileName(fn) ? sendErrorPage(ctx, HTTP_NOT_FOUND)
81
: statusCodeForMissingPerm(folder, 'can_upload', ctx) ? null
82
: loadFileCached(getUploadTempFor(join(folder.source, fn)), calcHash) // negligible memory leak
83
- .then(hash => ctx.body = hash, e => ctx.status = e?.code === 'ENOENT' ? HTTP_NOT_FOUND : HTTP_SERVER_ERROR)
83
+ .then(x => ctx.body = x.content, e => ctx.status = e?.code === 'ENOENT' ? HTTP_NOT_FOUND : HTTP_SERVER_ERROR)
84
const dest = uploadWriter(folder, folderUri, fn, ctx)
85
if (dest) {
86
ctx.req.pipe(dest).on('error', err => {
src/serveGuiFiles.ts
+32
-8
@@ -10,6 +10,7 @@ import { getPluginConfigFields, getPluginInfo, mapPlugins, pluginsConfig } from
10
import { authApis } from './api.auth'
11
import { ApiError } from './apiMiddleware'
12
import { join, extname, sep } from 'path'
13
+import { readdir } from 'fs/promises'
14
import {
15
CFG, debounceAsync, formatBytes, FRONTEND_OPTIONS, isPrimitive, newObj, onlyTruthy, parseFile,
16
enforceStarting, statWithTimeout, shortenAgent
@@ -30,6 +31,9 @@ _.each(FRONTEND_OPTIONS, (v,k) => defineConfig(k, v)) // define default values
31
32
function serveStatic(uri: string): Koa.Middleware {
33
const folder = (DEV ? 'dist/' : '') + uri.slice(2,-1)
34
+ const root = join(__dirname, '..', folder)
35
+ prewarmGuiAssets(root) // keep pkg snapshot files in memory before the reported runtime access loss can happen
36
+ .catch(e => console.error(`GUI asset prewarm failed: ${e?.code || e}`))
37
return async ctx => {
38
if (!logGui.get())
39
ctx.state.dontLog = true
@@ -41,20 +45,40 @@ function serveStatic(uri: string): Koa.Middleware {
45
if (ctx.method !== 'GET')
46
return ctx.status = HTTP_METHOD_NOT_ALLOWED
47
const serveApp = shouldServeApp(ctx)
44
- const fullPath = join(__dirname, '..', folder, serveApp ? '/index.html': ctx.path)
45
- const content = await parseFile(fullPath, raw => serveApp || !raw.length ? raw : adjustBundlerLinks(ctx, uri, raw), 1000)
48
+ const fullPath = join(root, serveApp ? '/index.html': ctx.path)
49
+ const cached = await parseGuiFile(fullPath)
50
.catch(e => {
51
if (!/^(?:ENOENT|EISDIR)$/.test(e?.code)) // not supposed to happen, and yet a user reported a strange behavior
52
console.error(`serveStatic/parseFile: ${String(e)}`)
53
return null
54
})
51
- if (content === null)
55
+ if (cached === null)
56
return ctx.status = HTTP_NOT_FOUND
53
- if (!serveApp)
54
- return serveFile(ctx, fullPath, MIME_AUTO, content)
57
+ if (!serveApp) {
58
+ const c = cached.content
59
+ return serveFile(ctx, fullPath, MIME_AUTO, { ...cached, content: !c.length ? c : adjustBundlerLinks(ctx, uri, c) })
60
+ }
61
// we don't cache the index as it's small and may prevent plugins change to apply
56
- ctx.body = await treatIndex(ctx, uri, String(content))
62
+ ctx.body = await treatIndex(ctx, uri, String(cached.content))
63
+ }
64
+
65
+ async function prewarmGuiAssets(dir: string) {
66
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
67
+ const fullPath = join(dir, entry.name)
68
+ if (entry.isDirectory()) {
69
+ await prewarmGuiAssets(fullPath)
70
+ continue
71
+ }
72
+ if (!entry.isFile())
73
+ continue
74
+ await parseGuiFile(fullPath)
75
+ }
76
+ }
77
+
78
+ function parseGuiFile(fullPath: string) {
79
+ return parseFile(fullPath, x => x, DEV ? 1000 : Infinity) // cache raw bytes so reverse-proxy URL rewriting can still use the current request context
80
}
81
+
82
}
83
84
function shouldServeApp(ctx: Koa.Context) {
@@ -194,12 +218,12 @@ function serveProxied(port: string | undefined, uri: string) { // used for devel
218
parseReqBody: false, // the dev GUI proxy serves app/assets, so avoid koa-better-http-proxy trying to reread ctx.req
219
proxyReqPathResolver: (ctx) =>
220
shouldServeApp(ctx) ? '/' : ctx.path,
197
- userResDecorator(res, data, ctx) {
221
+ userResDecorator(_res, data, ctx) {
222
return shouldServeApp(ctx) ? treatIndex(ctx, uri, String(data))
223
: adjustBundlerLinks(ctx, uri, data)
224
}
225
}) )
202
- return function (ctx, next) {
226
+ return function (ctx, _next) {
227
if (!logGui.get())
228
ctx.state.dontLog = true
229
return proxy(ctx, async () => {})
src/util-files.ts
+13
-12
@@ -3,7 +3,7 @@
3
import { access, chmod, mkdir, readFile, stat } from 'fs/promises'
4
import { Promisable, try_, wait, isWindowsDrive, haveTimeout } from './cross'
5
import { defineConfig } from './config'
6
-import { createWriteStream, mkdirSync, watch, ftruncate } from 'fs'
6
+import { createWriteStream, mkdirSync, watch, ftruncate, Stats } from 'fs'
7
import { basename, dirname } from 'path'
8
import glob from 'fast-glob'
9
import { IS_WINDOWS } from './const'
@@ -181,24 +181,25 @@ export function exists(path: string) {
181
}
182
183
// parse a file, caching unless timestamp has changed
184
-export const parseFileCache = new Map<string, { ts: Date, lastCheck: number, parsed: unknown }>()
184
+export interface CachedFile<T> { stats: Stats, content: T }
185
+export const parseFileCache = new Map<string, { stats: Stats, lastCheck: number, content: Promise<unknown> }>()
186
export async function loadFileCached<T>(path: string, loader: (path: string) => T, minInterval=0) {
187
const cached = parseFileCache.get(path)
188
const now = Date.now()
189
if (cached && now - cached.lastCheck < minInterval)
189
- return cached.parsed as T
190
- const ts = await statWithTimeout(path).then(x => x.mtime, e => {
191
- if (e?.message !== 'timeout')
192
- throw e
193
- return cached?.ts || new Date(0) // on timeout (e.g. thread pool saturated), serve cache if any, or attempt the loader
190
+ return { stats: cached.stats, content: await cached.content } as CachedFile<Awaited<T>>
191
+ const stats = await statWithTimeout(path).catch(e => {
192
+ if (e?.message === 'timeout' && cached)
193
+ return cached.stats // on timeout (e.g. thread pool saturated), serve cache if any
194
+ throw e
195
})
196
if (cached)
197
cached.lastCheck = now
197
- if (cached && Number(ts) === Number(cached.ts))
198
- return cached.parsed as T
199
- const parsed = loader(path)
200
- parseFileCache.set(path, { ts, parsed, lastCheck: now })
201
- return parsed
198
+ if (cached && Number(stats.mtime) === Number(cached.stats.mtime))
199
+ return { stats, content: await cached.content } as CachedFile<Awaited<T>>
200
+ const content = Promise.resolve(loader(path))
201
+ parseFileCache.set(path, { stats, content, lastCheck: now })
202
+ return { stats, content: await content } as CachedFile<Awaited<T>>
203
}
204
205
export async function parseFile<T>(path: string, parse: (raw: Buffer) => T, skipStatIfFresherThan=0) {