diff --git a/hub/server.test.ts b/hub/server.test.ts index f9e57c0..c02856a 100644 --- a/hub/server.test.ts +++ b/hub/server.test.ts @@ -126,6 +126,7 @@ beforeAll(async () => { writeFileSync(path.join(staticDir, 'logs', 'index.html'), 'logs') mkdirSync(path.join(staticDir, 'assets')) writeFileSync(path.join(staticDir, 'assets', 'app.js'), 'export {}') + writeFileSync(path.join(staticDir, 'assets', 'big.js'), 'export const x = 1\n'.repeat(500)) }) afterAll(() => { @@ -327,6 +328,11 @@ describe('static files', () => { const js = await fetch(`${base}/assets/app.js`) 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 () => { diff --git a/hub/server.ts b/hub/server.ts index 66dd1ca..76001d0 100644 --- a/hub/server.ts +++ b/hub/server.ts @@ -13,6 +13,7 @@ is not in the table of routes.ts is refused. */ import http from 'node:http' import { createReadStream } from 'node:fs' +import { createGzip } from 'node:zlib' import { stat } from 'node:fs/promises' import path from 'node:path' import { Readable } from 'node:stream' @@ -1053,12 +1054,22 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da 'content-length': info.size, } 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) if (req.method === 'HEAD') { res.end() 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) diff --git a/hub/upstream.test.ts b/hub/upstream.test.ts new file mode 100644 index 0000000..75fccd4 --- /dev/null +++ b/hub/upstream.test.ts @@ -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) + }) +}) diff --git a/hub/upstream.ts b/hub/upstream.ts index 7cb3942..9971e0e 100644 --- a/hub/upstream.ts +++ b/hub/upstream.ts @@ -40,10 +40,15 @@ const MAX_CACHED = 500 /* An app download or upload can take a while. */ 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 { #config: HubConfig #fetch: typeof fetch #cache = new Map() + #pending = new Map>() + #down = new Map() #now: () => number 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 hit = this.#cache.get(key) 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) - 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 }) + const call = this.#exec(server, path, new URLSearchParams(sorted), token, { method: 'GET' }, this.#config.timeoutMs).then( + (response) => { + this.#down.delete(server.id) + 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, headers: { accept: 'application/json', ...(init.headers as Record | 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 } catch (e) { + if (e instanceof UpstreamError) throw e throw new UpstreamError('unreachable', this.#describe(e, [server.token, token], timeoutMs)) } finally { clearTimeout(timer)