Hub: gzip and long cache for the console, clearer and faster upstream failures
Hub / check (push) Successful in 58s
Hub / image (push) Successful in 1m46s

- Static text files are gzipped and the hashed files under assets/ are
  cached for a year; the 1.6 MB bundle was sent whole on every visit.
- A proxy's error page (502 Bad Gateway) is reported as its HTTP status
  instead of a JSON parse error.
- Reads asked at the same time share one upstream request.
- After a timeout, reads of that server fail at once for 10 s instead of
  each waiting the full timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-11-07 10:07:22 +07:00
1 parent ba3cdd08d2
commit 18d98449b2
4 files changed
+94 -6

No files matched your search

+6
View File
@@ -126,6 +126,7 @@ beforeAll(async () => {
writeFileSync(path.join(staticDir, 'logs', 'index.html'), '<html>logs</html>') writeFileSync(path.join(staticDir, 'logs', 'index.html'), '<html>logs</html>')
mkdirSync(path.join(staticDir, 'assets')) mkdirSync(path.join(staticDir, 'assets'))
writeFileSync(path.join(staticDir, 'assets', 'app.js'), 'export {}') writeFileSync(path.join(staticDir, 'assets', 'app.js'), 'export {}')
writeFileSync(path.join(staticDir, 'assets', 'big.js'), 'export const x = 1\n'.repeat(500))
}) })
afterAll(() => { afterAll(() => {
@@ -327,6 +328,11 @@ describe('static files', () => {
const js = await fetch(`${base}/assets/app.js`) const js = await fetch(`${base}/assets/app.js`)
expect(js.headers.get('content-type')).toMatch(/^text\/javascript/) expect(js.headers.get('content-type')).toMatch(/^text\/javascript/)
expect(js.headers.get('cache-control')).toBe('public, max-age=31536000, immutable')
expect(root.headers.get('cache-control')).toBe('no-cache')
const big = await fetch(`${base}/assets/big.js`, { headers: { 'accept-encoding': 'gzip' } })
expect(big.headers.get('content-encoding')).toBe('gzip')
expect(await big.text()).toBe('export const x = 1\n'.repeat(500))
}) })
it('redirects a folder without the slash and 404s the rest', async () => { it('redirects a folder without the slash and 404s the rest', async () => {
+12 -1
View File
@@ -13,6 +13,7 @@ is not in the table of routes.ts is refused.
*/ */
import http from 'node:http' import http from 'node:http'
import { createReadStream } from 'node:fs' import { createReadStream } from 'node:fs'
import { createGzip } from 'node:zlib'
import { stat } from 'node:fs/promises' import { stat } from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
import { Readable } from 'node:stream' import { Readable } from 'node:stream'
@@ -1053,12 +1054,22 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
'content-length': info.size, 'content-length': info.size,
} }
if (ext === '.html') Object.assign(headers, HTML_HEADERS, { 'cache-control': 'no-cache' }) if (ext === '.html') Object.assign(headers, HTML_HEADERS, { 'cache-control': 'no-cache' })
// Vite puts a content hash in every name under assets/, so a browser may keep them for good.
else if (pathname.startsWith('/assets/')) headers['cache-control'] = 'public, max-age=31536000, immutable'
// Text is gzipped: the main bundle is about 1.6 MB as it is and a third of that compressed.
const gzip = /^(text\/|application\/(json|manifest)|image\/svg)/.test(String(headers['content-type'])) && info.size > 1024 && /\bgzip\b/.test(String(req.headers['accept-encoding'] ?? ''))
if (gzip) {
delete headers['content-length']
Object.assign(headers, { 'content-encoding': 'gzip', vary: 'accept-encoding' })
}
res.writeHead(200, headers) res.writeHead(200, headers)
if (req.method === 'HEAD') { if (req.method === 'HEAD') {
res.end() res.end()
return return
} }
createReadStream(file).on('error', () => res.destroy()).pipe(res) const source = createReadStream(file).on('error', () => res.destroy())
if (gzip) source.pipe(createGzip()).on('error', () => res.destroy()).pipe(res)
else source.pipe(res)
} }
const sweeper = setInterval(() => limiter.sweep(), 5 * 60_000) const sweeper = setInterval(() => limiter.sweep(), 5 * 60_000)
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { Upstream } from './upstream.ts'
import type { HubConfig } from './config.ts'
const server = { id: 'a', url: 'http://a.test', token: 't' }
const config = { cacheSeconds: 15, timeoutMs: 20 } as HubConfig
const ok = () => new Response(JSON.stringify({ status: 'ok', response: { n: 1 } }), { headers: { 'content-type': 'application/json' } })
describe('Upstream', () => {
it('shares one request among reads asked at the same time', async () => {
let calls = 0
const up = new Upstream(config, (async () => {
calls++
return ok()
}) as typeof fetch)
await Promise.all([up.call(server, 'x'), up.call(server, 'x'), up.call(server, 'x')])
expect(calls).toBe(1)
})
it('names the HTTP status when a proxy answers with a page', async () => {
const up = new Upstream(config, (async () =>
new Response('Bad Gateway', { status: 502, statusText: 'Bad Gateway', headers: { 'content-type': 'text/plain' } })) as typeof fetch)
await expect(up.call(server, 'x')).rejects.toThrow('HTTP 502 Bad Gateway')
})
it('after a timeout, fails reads of that server at once for a while', async () => {
let now = 0
let calls = 0
const hang = ((_url: string, init: RequestInit) => {
calls++
return new Promise((_resolve, reject) => init.signal?.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))))
}) as unknown as typeof fetch
const up = new Upstream(config, hang, () => now)
await expect(up.call(server, 'x')).rejects.toThrow('timed out after 20 ms')
await expect(up.call(server, 'y')).rejects.toThrow('timed out')
expect(calls).toBe(1)
now = 11_000
await expect(up.call(server, 'y')).rejects.toThrow('timed out')
expect(calls).toBe(2)
})
})
+35 -5
View File
@@ -40,10 +40,15 @@ const MAX_CACHED = 500
/* An app download or upload can take a while. */ /* An app download or upload can take a while. */
const WRITE_TIMEOUT_MS = 60_000 const WRITE_TIMEOUT_MS = 60_000
/* How long reads of a server that timed out fail at once (the console polls every few seconds). */
const DOWN_MS = 10_000
export class Upstream { export class Upstream {
#config: HubConfig #config: HubConfig
#fetch: typeof fetch #fetch: typeof fetch
#cache = new Map<string, Cached>() #cache = new Map<string, Cached>()
#pending = new Map<string, Promise<unknown>>()
#down = new Map<string, { until: number; error: UpstreamError }>()
#now: () => number #now: () => number
constructor(config: HubConfig, fetchImpl: typeof fetch = fetch, now: () => number = Date.now) { constructor(config: HubConfig, fetchImpl: typeof fetch = fetch, now: () => number = Date.now) {
@@ -58,13 +63,33 @@ export class Upstream {
const key = `${server.id}\n${path}\n${new URLSearchParams(sorted).toString()}` const key = `${server.id}\n${path}\n${new URLSearchParams(sorted).toString()}`
const hit = this.#cache.get(key) const hit = this.#cache.get(key)
if (hit !== undefined && hit.until > this.#now()) return hit.response if (hit !== undefined && hit.until > this.#now()) return hit.response
// A server that just timed out fails again at once, rather than holding every read for the whole timeout.
const down = this.#down.get(server.id)
if (down !== undefined && down.until > this.#now()) throw down.error
// The same read already on its way (the sidebar, the page and a panel ask together) is shared.
const pending = this.#pending.get(key)
if (pending !== undefined) return pending
const response = await this.#exec(server, path, new URLSearchParams(sorted), token, { method: 'GET' }, this.#config.timeoutMs) const call = this.#exec(server, path, new URLSearchParams(sorted), token, { method: 'GET' }, this.#config.timeoutMs).then(
if (this.#config.cacheSeconds > 0) { (response) => {
if (this.#cache.size >= MAX_CACHED) this.#prune() this.#down.delete(server.id)
this.#cache.set(key, { until: this.#now() + this.#config.cacheSeconds * 1000, response }) if (this.#config.cacheSeconds > 0) {
if (this.#cache.size >= MAX_CACHED) this.#prune()
this.#cache.set(key, { until: this.#now() + this.#config.cacheSeconds * 1000, response })
}
return response
},
(e: unknown) => {
if (e instanceof UpstreamError && e.message.startsWith('timed out')) this.#down.set(server.id, { until: this.#now() + DOWN_MS, error: e })
throw e
},
)
this.#pending.set(key, call)
try {
return await call
} finally {
this.#pending.delete(key)
} }
return response
} }
/* /*
@@ -155,8 +180,13 @@ export class Upstream {
signal: controller.signal, signal: controller.signal,
headers: { accept: 'application/json', ...(init.headers as Record<string, string> | undefined) }, headers: { accept: 'application/json', ...(init.headers as Record<string, string> | undefined) },
}) })
// A proxy in front of the server answers an outage with a page ("Bad Gateway"), not JSON.
if (!res.ok && !/json/i.test(res.headers.get('content-type') ?? '')) {
throw new UpstreamError('unreachable', `HTTP ${res.status} ${res.statusText}`.trim())
}
envelope = (await res.json()) as typeof envelope envelope = (await res.json()) as typeof envelope
} catch (e) { } catch (e) {
if (e instanceof UpstreamError) throw e
throw new UpstreamError('unreachable', this.#describe(e, [server.token, token], timeoutMs)) throw new UpstreamError('unreachable', this.#describe(e, [server.token, token], timeoutMs))
} finally { } finally {
clearTimeout(timer) clearTimeout(timer)