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) {