Skip to content

feat(static): support after ordering and warn when fallthrough: false blocks REST#1574

Merged
kriszyp merged 4 commits into
mainfrom
feat/static-after-ordering
Jul 8, 2026
Merged

feat(static): support after ordering and warn when fallthrough: false blocks REST#1574
kriszyp merged 4 commits into
mainfrom
feat/static-after-ordering

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Problem

The static handler registers before: 'authentication' by default (runFirst: true prior to 5.1), and the REST handler runs after: 'authentication' — so static always answers GETs first. That makes the documented history-mode SPA recipe a footgun:

static:
  files: 'dist/**'
  notFound:
    file: 'index.html'
    statusCode: 200
  fallthrough: false

This catch-all answers every unmatched GET itself — including GETs for exported REST resources — so the application's REST API silently becomes unreachable over GET (GET /MyResource/ returns index.html with a 200). All four create-harper SPA templates shipped this config; create-harper#109 removes it there and steers users toward hash routing, but Harper itself should support the history-mode pattern correctly and warn about the trap.

With this PR

One added line makes the recipe work as everyone assumed it did — the API is matched first, and the fallback only catches what's left:

rest: true

static:
  files: 'dist/**'
  # Let the REST handler match first; only unmatched URLs get the SPA fallback.
  after: 'rest'
  notFound:
    file: 'index.html'
    statusCode: 200
  fallthrough: false
GET /Dog/1          → 200 application/json   (REST resource)
GET /assets/app.js  → 200 text/javascript    (static file)
GET /app/settings   → 200 text/html          (index.html — client-side route)

History-mode routing and the REST API coexist. And anyone still running the old config now gets a startup warning in the log naming this exact fix.

Changes

  • after option on static (e.g. after: 'rest'): positions the handler after the named middleware so API routes match first and only unmatched URLs receive the notFound fallback. When after is given, the default before: 'authentication' hoist is suppressed — combining them would create an ordering cycle (static < authentication < rest < static), which would fall back to registration order with a warning.
  • before: false is now a documented, validated way to clear the default constraint without adding a new one (registration order applies). This previously worked only by accident of the falsy check in topoSort.
  • Validation for before/after values, matching the style of the other option validations in the plugin.
  • Startup warning (also re-checked on live reload of fallthrough) when fallthrough: false is used in the default pre-REST position, naming after: 'rest' as the remedy.
  • config-app.schema.json: the static component is now described (files, urlPath, index, extensions, fallthrough, notFound, before, after), so editors using the schema get completion and validation.

Verification

  • New unit tests: unitTests/server/static.test.js (12 tests) covering the ordering options passed to server.http, validation errors, and warning behavior — hand-rolled fake scope, plain assert, per house style.
  • End-to-end against a built dist with a Vite SPA app + exported resource:
    • With after: 'rest': GET /Greeting/200 application/json (the resource), GET /some/deep/link200 text/html (index.html fallback), GET / and built assets serve normally. History-mode routing and the REST API coexist.
    • With the old footgun config: the new warning appears in hdb.log at startup; behavior is unchanged (GET /Greeting/ still returns index.html) so nothing breaks for existing pure-static apps.
  • oxlint --deny-warnings and prettier --check clean on the changed files.
  • npx mocha unitTests/server/static.test.js — 12 passing. (The full test:unit:server suite fails identically on main in my local environment — pre-existing, unrelated.)

Follow-up

Docs: documentation#562 (draft) adds a Handler Ordering section to the Static Files page and corrects its SPA example to use after: 'rest'.

🤖 Generated with Claude Code

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request introduces 'before' and 'after' configuration options to the static file serving handler, allowing users to customize its position in the HTTP middleware chain. It also adds validation for these options, a warning when a non-fallthrough static handler might block REST API routes, and corresponding unit tests. The reviewer noted that because 'before' and 'after' are only evaluated at startup, live-reload changes to these options will be silently ignored, and suggested explicitly requesting a restart when they change.

Comment thread server/static.ts
@claude

claude Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

dawsontoth added a commit to HarperFast/documentation that referenced this pull request Jul 2, 2026
…tically

Matches HarperFast/harper#1574, which now requests a component restart
when before/after change at runtime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approving — nice fix for the static-shadows-REST GET footgun; after ordering resolves once at registration (no per-request cost) and the fallthrough:false warning is accurate.

One question I'm curious about: what's the use case where you'd actually reach for after here — is it for a specific static-vs-REST layout, or a general escape hatch? And given this change, is the current default ordering still the right one for the common case, or does this suggest the default should change so people don't need after in the first place?

— 🤖 KrAIs (Kris's review assistant)

@dawsontoth

Copy link
Copy Markdown
Contributor Author

There are legitimate cases for both.

For most Harper apps, static was for purely known, static asset serving. From a known map, those requests can be served the fastest, and they help speed up site metrics. It's a cheap thing to check, should be served before authentication, and then you check your meaty resources if they want to handle things.

But with SPAs that want to use real looking URLs and query string parameters for users, a 404 fallback to a known index.html (and scripted client side route loading) is a very common approach to serving content.

The static plugin couldn't handle this situation. We opted to push people to use hash based routing, which does have advantages. But with such a simple change, we can let people do what they want. They may not have a choice over the URLs they must handle.

@kriszyp

kriszyp commented Jul 4, 2026

Copy link
Copy Markdown
Member

real looking URLs and query string parameters for users, a 404 fallback to a known index.html

Is this really the ideal way to handle mapping a set (a glob?) of URLs ("real looking") to a static file (pass throug auth, REST, 404, fallback handler)? Should we add support for URL glob -> file path for explicit and fast mapping?

Comment thread server/static.ts
Comment thread server/static.ts
Comment thread server/static.ts Outdated
Comment thread server/static.ts Outdated
Comment thread config-app.schema.json Outdated
Comment thread config-app.schema.json Outdated
Comment thread unitTests/server/static.test.js Outdated
dawsontoth and others added 3 commits July 8, 2026 08:54
…lse` blocks REST

The static handler registers before authentication by default (and before
the REST handler, which runs after authentication), so a `fallthrough:
false` catch-all - the documented recipe for history-mode SPAs - answers
every unmatched GET itself, including GETs for exported REST resources.
The application's REST API becomes unreachable over GET with no signal to
the user about why.

- Support `after` in the static options (e.g. `after: 'rest'`) so the
  API is matched first and only unmatched URLs receive the `notFound`
  fallback. When `after` is given, the default `before: 'authentication'`
  hoist is suppressed - combining them would create an ordering cycle.
- Formalize `before: false` to clear the default constraint without
  adding a new one (registration order applies).
- Validate `before`/`after` and document them in the plugin JSDoc.
- Warn at startup (and on live reload of `fallthrough`) when
  `fallthrough: false` is used in the default pre-REST position, with the
  `after: 'rest'` remedy in the message.
- Describe the static component, including the new options, in
  config-app.schema.json so editors get completion and validation.

Verified end-to-end against a Vite SPA app with an exported resource:
with `after: 'rest'`, GET /Greeting/ returns the resource's JSON while
unmatched deep links still receive the index.html fallback - history-mode
routing and the REST API coexist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Scope only auto-restarts on unhandled option changes when the plugin has
no 'change' listener of its own - static has one, and before/after are
consumed once at registration, so a live edit to them was silently
ignored. Request a component restart so the middleware chain is rebuilt
with the new ordering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Treat a bare `before:`/`after:` key (YAML null) as unset, preserving the
  pre-validation behavior where null fell back to the default hoist.
- Warn on unresolved `before`/`after` names: topoSort silently drops
  references that match no registered handler (e.g. a typo, or the legacy
  `REST` config key), so the middleware chain now reports them on first
  dispatch — build time would false-positive, since chains are rebuilt as
  each component registers.
- `warnIfBlockingRest` reads live option values (a config save can change
  `fallthrough` and the ordering options together) and also fires for an
  explicit `before: 'authentication'`, which is the same blocking position.
- Flatten the nested ternary in the registration options.
- Schema: `additionalProperties: false` on the static block (matching the
  sibling component blocks) and `minLength: 1` on `before`/`after`.
- Tests: fake scope's fireChange now matches the real OptionsWatcher
  signature, plus coverage for null/empty ordering values, before+after
  combinations, and the unresolved-reference diagnostic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dawsontoth dawsontoth force-pushed the feat/static-after-ordering branch from 611c96b to 6865ecc Compare July 8, 2026 13:03
@dawsontoth

Copy link
Copy Markdown
Contributor Author

@kriszyp specifying your paths would be more performant, yes. But you're contending with a lot of people that want to do it with a 404, and have done it with 404 for a very long time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kriszyp kriszyp merged commit 035f717 into main Jul 8, 2026
47 checks passed
@kriszyp kriszyp deleted the feat/static-after-ordering branch July 8, 2026 16:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants