Middleware ordering and routing#397
Conversation
…using Scope API, refs #101
Components can now declare `before` and `after` dependencies in their http/ws/upgrade registration options to control relative middleware order without relying on registration order alone. Each scope's server proxy automatically injects the plugin name, so other components can reference it. REST now declares `after: 'authentication'`; static declares `before: 'authentication'` (configurable). `runFirst` is deprecated. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Components can now declare urlPath and host in their config (or directly in server.http options) to register middleware on an isolated chain that only handles matching requests. Sub-route chains auto-pull in transitive `after` dependencies from any route: if REST on /api declares `after: 'authentication'`, auth is included in the /api chain even though it was registered on the default route. The Scope proxy now injects urlPath and host from scope.options alongside the plugin name, so per-app routing requires no extra code in the plugin. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pure chain-building functions (topoSort, buildLinearChain, resolveDeps, matchesRoute, buildRoutedChain, makeCallbackChain) are now in server/middlewareChain.ts with no Harper-specific dependencies, making them directly importable in tests without rewire or mocking. 38 tests cover: topoSort ordering and cycle detection, buildLinearChain call order and short-circuit, resolveDeps transitive pulling, matchesRoute host/path matching, and makeCallbackChain integration scenarios including before/after constraints, sub-route dispatch, auto-pulled auth deps, and specificity ordering. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Ethan-Arrowood
left a comment
There was a problem hiding this comment.
great work!
do any other built-in plugins need ordering updates? I noticed you modified static and rest, but should something like authentication be set first? What about other server middlewares like the websocket stuff in rest, or the rest ones in graphql? Or is that all follow up?
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When an application is configured with a urlPath (e.g. /foo), requests routed to its sub-chain now have the prefix stripped from pathname and url, so REST resources and static files registered at their own root paths resolve correctly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Reviewed; no blockers found. |
There was a problem hiding this comment.
These changes will be neat. Picking at it with gemini and only finding some edge cases...
AI Review: Middleware ordering and routing
I have completed a deep review of this PR. The implementation of Kahn's algorithm for topological sorting and the new routing dispatcher is well-structured and provides a significant improvement over the legacy runFirst boolean.
However, I identified several issues related to trailing slash handling and state consistency in the stripPrefix proxy that should be addressed before merging.
🤖 Generated by Gemini CLI
| for (const entry of [...included]) { | ||
| if (entry.after) { | ||
| const dep = nameToEntry.get(entry.after); | ||
| if (dep && !included.has(dep)) { |
There was a problem hiding this comment.
AI Review [High]: Trailing slash in urlPath causes match failure. If urlPath is '/api/', this becomes startsWith('/api//'), failing for '/api/foo'. Suggested fix: Normalize urlPath (remove trailing slash) before comparison. 🤖
| } | ||
|
|
||
| /** | ||
| * Returns true when `request` satisfies the route's host and urlPath constraints. |
There was a problem hiding this comment.
AI Review [High]: stripPrefix returns malformed paths if prefix has trailing slash. e.g., /api/foo becomes foo instead of /foo. Also, this captures stale path state if middleware modifies the request later. 🤖
| } | ||
|
|
||
| export interface HttpOptions extends ServerOptions { | ||
| /** @deprecated Use `before` or `after` for explicit ordering instead */ |
There was a problem hiding this comment.
AI Review [Low]: UpgradeOptions and WebSocketOptions were not updated to include these new fields, leading to TS errors for those listener types. 🤖
| const successors: number[][] = Array.from({ length: n }, () => []); | ||
| const inDegree = new Int32Array(n); | ||
| const addEdge = (from: number, to: number) => { | ||
| successors[from].push(to); |
There was a problem hiding this comment.
AI Review [Low]: Silent fallback on dependency cycles. Server.ts does not provide an onCycle callback, resulting in non-deterministic ordering if cycles exist. 🤖
…ions types - Normalize urlPath in matchesRoute, stripPrefix, and route grouping so '/api' and '/api/' are equivalent (prevents malformed paths and missed matches). - Compute stripPrefix lazily on each access so downstream pathname mutations remain reflected through the proxy. - Wire an onCycle callback through makeCallbackChain that logs a warning when before/after ordering produces a cycle. - Consolidate the routing/ordering option fields onto ServerOptions so that WebSocketOptions and UpgradeOptions inherit them (no more silent drops). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The MQTT WebSocket listener and the graphqlQuerying HTTP listener both require an authenticated user on the request; declare that explicitly so they get placed after authentication in the chain. (REST.ts and graphql.ts changes are formatter-driven.) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The lockfile's project metadata still declared mqtt at ~4.3.8 even though package.json was bumped to ^5.15.1 in 57be326, causing npm ci to fail in CI (also failing on main). npm install was needed to bring the lockfile back in sync; this also pulls in transitive deps that were missing (bufferutil, utf-8-validate, broker-factory, worker-timers, etc.). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
| securePort?: number; | ||
| runFirst?: boolean; | ||
| } | ||
| export interface UpgradeOptions extends ServerOptions {} |
There was a problem hiding this comment.
1. UpgradeOptions extends ServerOptions but onUpgrade doesn't implement the new fields
What: This change adds name, before, after, urlPath, and host to UpgradeOptions via extends ServerOptions. But onUpgrade in http.ts still stores only { listener, port } — those new fields are silently dropped and never reach makeCallbackChain, so ordering constraints on upgrade listeners are no-ops at runtime.
Why it matters: The type contract now promises that upgrade handlers participate in named ordering, but the implementation doesn't deliver it. A plugin that writes server.upgrade(fn, { before: 'authentication' }) gets silent misfiling.
Suggested fix: Either also update onUpgrade to extract and forward the ordering fields (mirroring the httpServer logic), or revert UpgradeOptions to its own interface until the implementation is ready:
| export interface UpgradeOptions extends ServerOptions {} | |
| export interface UpgradeOptions extends ServerOptions {} |
(no change to the line — the fix is in onUpgrade: store name/before/after/urlPath/host alongside listener and port, then call makeCallbackChain on the typed entry.)
UpgradeOptions and WebSocketOptions advertise these fields via ServerOptions,
but onUpgrade and onWebSocket were only storing { listener, port } in their
chain arrays, silently dropping ordering and routing config. This made the
new mqtt `after: 'authentication'` constraint a no-op at runtime.
Build the same shape of entry as httpServer so the ordering/routing fields
reach makeCallbackChain.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
| return function dispatch(request: any) { | ||
| for (const route of subRouteChains) { | ||
| if (matchesRoute(request, route)) { | ||
| return route.chain(route.urlPath ? stripPrefix(request, route.urlPath) : request); | ||
| } | ||
| } | ||
| return defaultChain(request); | ||
| }; |
There was a problem hiding this comment.
The dispatch function only accepts and forwards request, but this module is also used for upgradeChains (invoked as (request, socket, head)) and websocketChains (invoked as (ws, request, chainCompletion)). When any listener in those chains is registered with a urlPath or host — now guaranteed to happen via the Scope proxy that automatically injects urlPath/host from config — buildRoutedChain is activated and the extra arguments are silently dropped:
- Upgrade chains:
socketandheadare lost; upgrade listeners receive(request, callback)instead of(request, socket, head, callback). - WebSocket chains:
wsis bound to therequestparameter;matchesRoute(ws, route)fails becausewshas nopathname; the default chain is called asdefaultChain(ws), so listeners receive(ws, callback)instead of(ws, request, chainCompletion, callback), losing the HTTP request and chain-completion promise.
The Scope proxy (new in this PR) injects urlPath/host into every ws and upgrade call, so any plugin configured with urlPath in config.yaml will trigger this path immediately.
buildRoutedChain was designed for the HTTP listener signature. Upgrade and WebSocket chains need either their own dispatch wrappers that forward all arguments, or the routing for those chains needs to key off the correct positional argument (request for upgrade, the second arg for WebSocket).
buildRoutedChain's dispatch was hardcoded to (request) — fine for HTTP, but the same chain code is used by upgrade (request, socket, head) and ws (ws, request, chainCompletion). Once a Scope-injected urlPath/host activated buildRoutedChain on those, socket/head and ws/chainCompletion were silently dropped. makeCallbackChain and buildRoutedChain now accept a requestArgIndex; dispatch extracts the request from that position, substitutes the prefix-stripped request back in, and forwards all other args. Upgrade chains use index 0, WebSocket chains use index 1. Added 3 tests covering both signatures. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
cb1kenobi
left a comment
There was a problem hiding this comment.
Gave this a test with my app and things seemed to good. Nice work!
Named middleware ordering and per-route middleware chains
Problem
Harper's HTTP middleware stack had two coarse ordering mechanisms: registration order and the
runFirstflag (a boolean override to jump to the front). This made it impossible for a component like REST to declaratively state "I depend on authentication" ? the ordering was implicit and config-order-sensitive.There was also no way to scope middleware to specific URL paths or virtual hosts. All components registered globally and were responsible for filtering requests themselves.
Changes
before/afterdependency-based ordering (Server.ts,middlewareChain.ts)Components can now declare
before: 'name'orafter: 'name'in theirserver.http()options. A Kahn's algorithm topological sort uses the original registration index as a tiebreaker, so config file order is preserved when no explicit constraints exist between two entries.runFirstis deprecated but continues to work.Automatic component naming (
Scope.ts)The
scope.serverproxy now injects the plugin's name (and itsurlPath/hostconfig) into everyserver.http/request/ws/upgradecall. Plugins get named for free ? no extra code needed.REST declares its auth dependency (
REST.ts)REST now carries
after: 'authentication', ensuring auth always runs before REST regardless of config order.Static no longer forces itself to the front (
static.ts)runFirst: trueis removed. Static now participates in normal ordering; itsbefore: 'authentication'default (configurable) keeps the preferredstatic ? auth ? restchain when they appear in that order in config.Per-route middleware chains (
middlewareChain.ts)Components configured with
urlPathorhostget their own isolated middleware chain. The dispatcher routes requests to the best-matching chain (host+path > host > path; longer prefix wins ties). Unmatched requests fall to the default chain. Sub-route chains automatically pull in transitiveafterdependencies from any route ? so if REST on/apideclaresafter: 'authentication', auth is included in the/apichain even though auth registered on the default route. Auth runs once per request (requests hit exactly one chain).Pure utility module + 38 unit tests (
middlewareChain.ts,unitTests/server/middlewareChain.test.js)All chain-building logic lives in a dependency-free module, directly importable in tests without mocking. Tests cover topoSort (ordering, cycle detection, first/last name semantics), dependency resolution, route matching, and full
makeCallbackChainintegration scenarios.Addresses #395 and #416
Documentation