From e5c3cd4b97457081aed1b69c02c22272fa9a8a4c Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sat, 22 Aug 2026 13:32:04 +0100 Subject: [PATCH] perf(admin): stop wrapping baked assets in a 7500-literal `+` chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit webui/generate.mjs split each admin-panel asset's base64 into 120-char Go string literals joined with `+` (~7,550 literals, ~7,538 `+` ops across index.html + the JS/CSS bundle). A compiled Go binary folds that at compile time for free, but this plugin loads under Yaegi (github.com/traefik/yaegi), which folds a chain of N literal `+` operations at import time by repeatedly reallocating and copying the whole already-folded prefix: O(N^2). Measured: ~186ms of a ~223-240ms package import was this folding alone, versus ~3-4ms to decode the asset bytes. Replace the 120-char LINE_WIDTH with a 1,000,000-char CHUNK_CHARS cap. Every asset this panel ships today (largest ~516,000 base64 chars) fits in a single literal, so goBase64Expr now emits one decodeAdminAsset call with zero `+` folds per asset. The cap still bounds a future, much larger bundle to a small number of large chunks rather than one unbounded line; single-line Go string literals in this size class were verified against gofmt/go vet/go build before picking the constant. No behavior change: decoded asset bytes, contentType strings, and the asset map keys are unaffected by how the base64 is chunked into source literals — see the following regen commit for the byte-identity proof. --- webui/generate.mjs | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/webui/generate.mjs b/webui/generate.mjs index 37d9091..a318478 100644 --- a/webui/generate.mjs +++ b/webui/generate.mjs @@ -111,16 +111,36 @@ function goStringLiteral(s) { /** * goBase64Expr renders base64Body (a base64 string) as a Go expression - * calling decodeAdminAsset, split into LINE_WIDTH-character chunks joined - * with `+` — a single 400KB-wide line is valid Go but painful for humans - * and diff tools alike; chunking keeps every line reviewable. + * calling decodeAdminAsset, split into at most CHUNK_CHARS-character + * literals joined with `+`. + * + * This used to split at a human-readable LINE_WIDTH of 120 chars — + * ~7,550 literals / ~7,538 `+` operations for this panel's ~680KB of + * base64. That's fine for a compiled Go binary (constant string + * concatenation folds at compile time), but this plugin is loaded by + * Yaegi (github.com/traefik/yaegi — Traefik's plugin interpreter), which + * folds a chain of N literal `+` operations at *import* time, by + * repeatedly reallocating and copying the whole already-folded prefix: + * O(N^2). Measured: that alone cost ~186ms of a ~223-240ms package + * import — the dominant share of Traefik's plugin-boot latency for this + * repo, versus ~3-4ms for decoding the asset bytes themselves. + * + * CHUNK_CHARS is set well above this panel's current largest asset + * (~516,000 base64 chars for the main JS bundle), so every asset today + * emits exactly one literal — zero `+` folds, zero O(N^2) cost. It still + * caps chunk size (rather than emitting one unbounded literal) so a + * future, much larger bundle degrades to a handful of large chunks + * instead of one arbitrarily long source line; gofmt/go vet/go build + * were verified against single-line string literals in this size class + * (see the perf-fix commit that introduced this constant), so this cap + * sits well inside that verified range. */ -const LINE_WIDTH = 120 +const CHUNK_CHARS = 1_000_000 function goBase64Expr(base64Body) { if (base64Body.length === 0) return 'decodeAdminAsset("")' const chunks = [] - for (let i = 0; i < base64Body.length; i += LINE_WIDTH) { - chunks.push(goStringLiteral(base64Body.slice(i, i + LINE_WIDTH))) + for (let i = 0; i < base64Body.length; i += CHUNK_CHARS) { + chunks.push(goStringLiteral(base64Body.slice(i, i + CHUNK_CHARS))) } return `decodeAdminAsset(\n\t\t${chunks.join(' +\n\t\t')},\n\t)` }