Hub: gzip and long cache for the console, clearer and faster upstream failures
- 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:
1 parent
ba3cdd08d2
commit
18d98449b2
4 files changed
+94
-6
No files matched your search
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user