Skip to content

perf(public): serve dev-UI assets from cacheable route instead of inlining ~1MB per page - #3012

Merged
bpamiri merged 2 commits into
developfrom
peter/issue-2959-devui-asset-delivery
Jun 11, 2026
Merged

bpamiri merged 2 commits into
developfrom
peter/issue-2959-devui-asset-delivery

Conversation

@bpamiri

@bpamiri bpamiri commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

What

Dev-UI pages (/wheels/info, /wheels/routes, /wheels/migrator, /wheels/guides, test-runner HTML pages, …) rendered through vendor/wheels/public/layout/_header.cfm inlined ~1.1MB into every response: four JS bundles + two CSS bundles via cfinclude and a ~53KB base64 icon-font data URI — all under no-cache HTML headers, so the full payload re-downloaded on every page view. This is slice P11 of #2959 (the asset-delivery half; the cli.cfm CliBridge decomposition remains open on the issue).

  • New wheelsAssets route (/wheels/assets/*[file]) served by wheels.Public.assets() with Cache-Control: public, max-age=31536000, immutable. Mirrors the shipped guideImage() canonicalize-and-confine pattern, extended to allow subdirectory paths (css/woff_files/icons.woff2) while rejecting traversal, absolute/backslash paths, and non-asset extensions ($resolveDevAssetPath() allowlist + canonical-prefix confinement + $blockInProduction() gate).
  • devAssetUrl() builds versioned URLs (?v=<framework version>) through urlFor(), so rewrite-on/partial/off all work. Defined on the component rather than helpers.cfm: included-UDFs are not externally callable on Adobe CF (the pre-existing RouteTesterHardeningSpec error below demonstrates exactly that engine difference), while a component method works in every _header.cfm include chain AND is unit-testable. This is the one deliberate deviation from the scout plan.
  • _header.cfm: dead request.wheelsInternalAssetPath line removed; icon-font data-URI init block removed (_header_simple.cfm keeps its own copy — error pages stay fully inline by design); inline blobs replaced with <link>/<script> tags; the Icons @font-face override now points at the served woff2 (same cascade mechanism as Icons not rendering correctly in Guides section #2421/fix(view): inline Semantic UI icon font so dev page icons render #2563, ~53KB lighter).
  • The route lives at /wheels/assets/* (not /wheels/public/assets/*) because the stock urlrewrite.xml excludes the latter prefix from the index.cfm rewrite. urlrewrite.xml untouched.

Measured: /wheels/info document shrank from ~1.1MB to ~68KB; assets fetch once and cache for a year keyed on framework version.

Test evidence

New spec: vendor/wheels/tests/specs/security/DevAssetServingSpec.cfc (10 specs — TDD: confirmed failing with method-not-found/route-missing before implementation).

Run Engine Result
Baseline (develop), full suite Lucee 7 + SQLite (docker) 4293 pass / 12 fail / 0 error
Final (this branch), full suite Lucee 7 + SQLite (docker) 4303 pass / 12 fail / 0 error (= baseline + 10 new specs, zero new failures)
Security area Lucee 7 + SQLite 269 pass / 0 fail / 0 error
Security area Adobe CF 2023 + SQLite (docker) 268 pass / 0 fail / 1 error (pre-existing, see below); DevAssetServingSpec itself 10/10

Pre-existing failures (present identically on the develop state in the same harness, unrelated to this change):

  • Lucee: 12 fails in wheels.tests.specs.internal.testClientSpec — the TestClient makes loopback HTTP requests to the host-mapped port, unreachable from inside the container (status 0 on every request). Green in CI.
  • Adobe 2023: 1 error in RouteTesterHardeningSpec ("$$findMatchingRoutes not found in Public.cfc") — the spec calls a helpers.cfm-included UDF on the instance, which Adobe does not expose externally. Reproduced byte-for-byte with this branch's changes stashed.

Hand-testing (live HTTP against the docker containers, both engines)

  • /wheels/assets/css/semantic.min.css?v=x → 200, Cache-Control: public, max-age=31536000, immutable, Content-Type: text/css (Lucee and Adobe 2023).
  • Nested font css/woff_files/icons.woff2 → 200, font/woff2.
  • --path-as-is /wheels/assets/../Public.cfc, /wheels/assets/helpers.cfm, /wheels/assets/js/nope.js → all 404 (both engines).
  • /wheels/info, /wheels/routes, /wheels/guides, /wheels/migrator, /wheels/packages, /wheels/core/tests (HTML), /wheels/app/tests (HTML) all render with versioned asset links; zero inline jQuery/semantic blobs; zero data URIs.
  • 404 error page still renders fully inline via _header_simple.cfm (data URI present — untouched path confirmed working).

Verified over curl, not a desktop browser — no interactive icon-glyph/JS-widget eyeballing was done; the @font-face override mechanism is unchanged from the shipped data-URI fix apart from the src URL.

Refs #2959

🤖 Generated with Claude Code

…ining ~1MB per page

Every dev-UI page rendered through _header.cfm inlined ~1.1MB into the
response: four JS bundles and two CSS bundles via cfinclude plus a ~53KB
base64-encoded Semantic UI icon font data URI, all under no-cache HTML
headers, so the full payload re-downloaded on every page view.

Serve those assets from a new wheelsAssets route (/wheels/assets/*[file])
on wheels.Public instead:

- assets() action mirrors guideImage()'s canonicalize-and-confine shape
  but allows subdirectory paths (css/woff_files/icons.woff2) while
  rejecting traversal, absolute/backslash paths, and non-asset
  extensions via $resolveDevAssetPath(); responses carry
  Cache-Control: public, max-age=31536000, immutable.
- devAssetUrl() builds versioned URLs (?v=<framework version>) through
  urlFor(), so all three URL-rewriting modes work. It lives on the
  component (not helpers.cfm) so every _header.cfm include chain can
  call it and specs can exercise it on an instance.
- _header.cfm drops the dead request.wheelsInternalAssetPath line, the
  application-scoped icon-font data-URI init block, and the megabyte
  cfinclude blobs in favor of link/script tags; the Icons @font-face
  override now points at the served woff2 (same cascade mechanism as
  the data-URI fix from issue 2421, ~53KB lighter per page).
- _header_simple.cfm (error pages) intentionally stays fully inline so
  error rendering never depends on routing.

The route lives at /wheels/assets/* (not /wheels/public/assets/*)
because the stock urlrewrite.xml excludes the latter prefix from the
index.cfm rewrite.

Refs #2959

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

@wheels-bot wheels-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wheels Bot — Reviewer A

TL;DR: This is a solid, well-scoped perf change that moves ~1MB of inlined JS/CSS from every dev-UI page load to a versioned, immutable-cached route. The path-traversal protection is layered correctly (charset allowlist → .. check → absolute-path check → canonical prefix confinement → extension allowlist → FileExists), the route naming is consistent with the wheels namespace convention, and the PR body documents hand-testing on both Lucee and Adobe 2023. One concrete test coverage gap: the existing PublicComponentProductionSpec gated-handler allowlist was not updated to include assets(), so a future PR could remove $blockInProduction() from the new action without CI catching it. Verdict: comment — no correctness, cross-engine, or security issues found; one test maintenance nit.


Tests

assets() missing from PublicComponentProductionSpec.cfc::gatedHandlers

File: vendor/wheels/tests/specs/security/PublicComponentProductionSpec.cfc, lines 140–152.

var gatedHandlers = [
    "consoleeval",
    "cli",
    "packagelist",
    ...
    "guideImage",
    "mcp"   // <-- "assets" is absent
];

Every other production-gated action on Public.cfc is listed here; the spec exists precisely to lock down the list so that removing a $blockInProduction() guard triggers a CI failure. assets() calls $blockInProduction() correctly today, but without an entry in this list a future maintenance PR could silently drop the guard. assets() should be added to gatedHandlers exactly as "assets" (the case-insensitive regex pattern already handles the function assets() declaration correctly).

Suggested fix: add "assets" to the array in PublicComponentProductionSpec.cfc (after "guideImage" fits thematically).


No URL-encoded traversal test in DevAssetServingSpec.cfc

$resolveDevAssetPath() correctly rejects URL-encoded traversal (e.g. %2e%2e/etc/passwd) because % is not in the charset allowlist [^A-Za-z0-9_\-./]. However, the traversal test suite in DevAssetServingSpec.cfc only covers literal .., backslashes, and leading slashes. The sibling PathTraversalSpec.cfc does exercise URL-encoded inputs against guideImage-era helpers; the new resolver has the same input surface and is worth a parallel case. This is a coverage gap, not a vulnerability.


Docs

The changelog fragment at changelog.d/dev-ui-cacheable-assets.changed.md is present and correctly uses the fragment system rather than editing CHANGELOG.md directly. No doc update required beyond that — this is a framework-internal perf change with no user-facing API surface.


Commits

Single commit perf(public): serve dev-UI assets from cacheable route instead of inlining ~1MB per page — type perf, scope public, header 87 chars ≤ 100, not ALL-CAPS. Passes commitlint.


@wheels-bot

wheels-bot Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Wheels Bot — Reviewer B (round 1)

A's review is accurate. Both findings are verified against the diff. No sycophancy, no false positives, and the one potential naming discrepancy I spot-checked (route defined as name = "assets" while devAssetUrl() uses route = "wheelsAssets") turns out to be correct framework behavior: scoping.cfc:53 auto-prefixes the parent namespace name, so name = "assets" inside .namespace(name = "wheels") becomes wheelsAssets. A was right not to flag it.

Sycophancy

None detected. A backed the approve-adjacent verdict with evidence (layered traversal guard, test counts, commit lint check, hand-test log).

False positives

None detected. Every claim A made is accurate against the diff.

Missed issues

None detected. I re-scanned for:

  • Cross-engine cfheader / cfcontent / cffile calls: all use explicit named attributes, not attributeCollection = arguments, so invariant Fixed bug in $findRoute() that causes blow up on unmatched named route #10 does not apply.
  • Left(str, N) with N = 0 (Lucee 7 crash): the Left(arguments.file, 1) guard passes 1, not 0.
  • Route order: the new assets route is placed before .root() inside the wheels namespace scope, which is correct per the documented ordering rule.
  • Production-guard coverage gap: confirmed — "assets" is absent from gatedHandlers in PublicComponentProductionSpec.cfc (list ends at "guideImage", "mcp" on lines 151-152).

Verdict alignment

A issued comment with two concrete, actionable findings (add "assets" to the gatedHandlers lock-list; add a URL-encoded traversal test). Consistent with the findings — neither is a correctness block, both are CI-hardening improvements worth making.

Convergence

Aligned. A's two findings are correct and actionable. The gatedHandlers gap is the more meaningful one: the spec exists specifically to prevent silent removal of $blockInProduction() guards, and omitting "assets" defeats that purpose for the new action. Joint recommendation: apply both fixes (add "assets" to the gatedHandlers array; add a %2e%2e-style URL-encoded traversal test case to DevAssetServingSpec.cfc) then merge.

- Add `assets` to PublicComponentProductionSpec.gatedHandlers so the
  static-coverage lock-list catches future removal of $blockInProduction()
  from wheels.Public.assets().
- Add a URL-encoded traversal case (`%2e%2e/etc/passwd` etc.) to
  DevAssetServingSpec — parallel to the guideImage-era PathTraversalSpec
  coverage; the charset allowlist [^A-Za-z0-9_\\-./] in
  $resolveDevAssetPath() rejects `%`, so these inputs resolve to empty.

Refs #2959, #3012

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
@wheels-bot

wheels-bot Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Wheels Bot — Address Review (round 1)

Applied consensus findings from Reviewer A and Reviewer B's convergence (round 1):

  • Added "assets" to gatedHandlers in vendor/wheels/tests/specs/security/PublicComponentProductionSpec.cfc:152. Slots between "guideImage" and "mcp" to keep the list aligned with the order entries appear in Public.cfc. The static-coverage spec now locks wheels.Public.assets() into the same lock-list as every other production-gated handler, so a future maintenance PR can't silently drop $blockInProduction() from the new action without CI failing.
  • Added a URL-encoded traversal test to vendor/wheels/tests/specs/security/DevAssetServingSpec.cfc:61. New it("returns empty for URL-encoded traversal payloads", ...) exercises %2e%2e/etc/passwd, %2E%2E%2Fetc%2Fpasswd, css/%2e%2e/Public.cfc, and %2fetc/passwd. The charset allowlist [^A-Za-z0-9_\-./] in $resolveDevAssetPath() rejects %, so each input resolves to "". Mirrors the guideImage-era PathTraversalSpec coverage Reviewer A cited.

Test evidence

Core suite re-run via the local Wheels CLI server (Lucee 7 + SQLite, after ?reload=true):

Scope Result
Full core suite 4317 pass / 0 fail / 0 error
wheels.tests.specs.security.DevAssetServingSpec 11 pass / 0 fail / 0 error (was 10)
wheels.tests.specs.security.PublicComponentProductionSpec 45 pass / 0 fail / 0 error (was 44)

Net +2 specs (the URL-encoded it() adds one; the "assets" entry in gatedHandlers adds one via the static-coverage loop). Zero regressions.

The new commit will trigger a fresh Reviewer A run on the updated SHA. Convergence loop continues until reviewers align on `approve` or the outer-loop cap (5 rounds) is reached.

@wheels-bot wheels-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wheels Bot — Reviewer A

TL;DR: The test(security) commit at 3b2d985a8 directly addresses both findings from my prior round: "assets" is now in PublicComponentProductionSpec::gatedHandlers and a URL-encoded traversal suite (%2e%2e/etc/passwd, %2E%2E%2Fetc%2Fpasswd, css/%2e%2e/Public.cfc, %2fetc/passwd) is now in DevAssetServingSpec. The overall PR remains clean across correctness, security, cross-engine, tests, docs, and commits. Verdict: approve.


Tests

Both previous findings resolved.

assets() now in PublicComponentProductionSpec::gatedHandlers

vendor/wheels/tests/specs/security/PublicComponentProductionSpec.cfc, line 152: "assets" appears after "guideImage" in the gatedHandlers array. A future maintenance PR that silently drops $blockInProduction() from wheels.Public::assets() will now trigger a CI failure on the security suite. ✓

URL-encoded traversal test added

vendor/wheels/tests/specs/security/DevAssetServingSpec.cfc, lines 63–82: four URL-encoded traversal payloads (%2e%2e/..., %2E%2E%2F..., css/%2e%2e/..., %2f...) now have their own it() block. The reason they are blocked is documented inline: % is not in the charset allowlist [^A-Za-z0-9_\-./], so none of these inputs reaches the filesystem. Parallels the existing guideImage-era PathTraversalSpec coverage. ✓


Commits

Both commits pass commitlint:

  • perf(public): serve dev-UI assets from cacheable route instead of inlining ~1MB per page — type perf, scope public, 87 chars ≤ 100, signed off by Peter Amiri ✓
  • test(security): address Reviewer A/B consensus findings (round 1) — type test, scope security, 65 chars ≤ 100, signed off by claude[bot] ✓

@wheels-bot

wheels-bot Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Wheels Bot — Reviewer B (round 1)

A's review checks out. Both round-1 findings were addressed correctly, A's factual claims are accurate against the actual diff, and the approval verdict is well-founded.

Sycophancy

None detected. A's approval cites specific files, line numbers, and test payloads — not a bare LGTM.

False positives

None detected. I verified both claims against the diff:

  • "assets" added to gatedHandlers in PublicComponentProductionSpec.cfc: confirmed in the diff immediately after "guideImage". ✓
  • URL-encoded traversal payloads in DevAssetServingSpec.cfc: all four (%2e%2e/etc/passwd, %2E%2E%2Fetc%2Fpasswd, css/%2e%2e/Public.cfc, %2fetc/passwd) are present; the inline comment correctly explains the rejection mechanism (the % character falls outside the [^A-Za-z0-9_\-./] allowlist). ✓

A's commit-header char count for the first commit is off by one (88, not 87), but inconsequential — well under the 100-char limit.

Missed issues

None detected. I re-scanned the diff for:

  • Cross-engine compatibility: cfheader/cfcontent/cffile all use named attributes (not attributeCollection), so invariant Fixed bug in $findRoute() that causes blow up on unmatched named route #10 (Adobe CF 2023/2025) does not apply. Left(arguments.file, 1) is guarded by the prior empty-string check, so invariant adding better description for hasManyCheckBox #8 (Lucee 7 Left(str, 0)) does not apply.
  • The $blockInProduction() gate is the first statement in assets(). ✓
  • $resolveDevAssetPath and $devAssetMimeType are public with $ prefix, satisfying cross-engine invariant New master - readme #7. ✓
  • devAssetUrl is intentionally public without a $ prefix — it is a layout-callable helper (not an internal helper), consistent with the PR's reasoning and not a security concern (it builds a URL from a hardcoded route and framework version, does not serve files, and does not need a production gate).
  • Commits: both headers are valid conventional-commit format, within 100 chars, correct types, signed off. ✓
  • Changelog fragment present at changelog.d/dev-ui-cacheable-assets.changed.md. ✓

Verdict alignment

Approve is consistent with the findings: both round-1 blockers are resolved and no new issues are present.

Convergence

Aligned on approve. The consensus is that this PR is review-clean on SHA 3b2d985: security gating is correct, path confinement is sound, test coverage addresses traversal (both raw and URL-encoded), and the production guard is CI-enforced going forward.

@bpamiri
bpamiri merged commit 3ab921e into develop Jun 11, 2026
7 checks passed
@bpamiri
bpamiri deleted the peter/issue-2959-devui-asset-delivery branch June 11, 2026 15:11
bpamiri added a commit that referenced this pull request Jun 22, 2026
…vice (#3242)

The dev-UI / CLI dispatcher vendor/wheels/public/views/cli.cfm was a
935-line god template with a 44-case cfswitch whose command handlers could
not be unit-tested (the template only runs under a full HTTP request
context). This was the remaining open item of issue #2959 (review finding
P2) — the recursive-dispatch + envelope defects shipped in #2966 and the
~1MB inline-asset defect in #3012.

Extract every command into wheels.public.CliBridge — a plain, stateless
component with one method per command and an explicit command->method
allowlist. cli.cfm (now ~112 lines) builds the response envelope preamble
(security gate, lazy migration discovery), constructs a context, checks
handles(), and calls dispatch(); the result is merged into the envelope.

Behavior-preserving: handlers are the exact former case bodies with
request.wheels.params -> arguments.params, preamble reads -> context.*, and
framework primitives (model(), get(), $cliFormatMigrationStatus(),
$cliResolveDumpPath()) reached through context.host. An unknown command
stays a silent no-op, matching the former default-less switch.

Security: dispatch() only ever invoke()s a method named in the allowlist,
and params is passed as a single named argument (never spread), so a
query-string key cannot become an arbitrary function argument.

The bridge is stateless, so one instance is cached on application.wheels
(via $cliBridge()) and shared across requests; ?reload=true re-creates it.

Verified: cli/security/migrator suites green on Lucee 7; live dispatch of
read-only + POST write-path commands + the SEC-4 mutation gate + unknown
no-op + reload all confirmed against a running server; cross-engine matrix
(Lucee 7 + Adobe 2023, SQLite) shows the cli bundle 126/126 pass with only
pre-existing unrelated failures.

Refs #2959

Signed-off-by: Peter Amiri <petera@pai.com>
Co-authored-by: Peter Amiri <petera@pai.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant