Skip to content

Middleware ordering and routing#397

Merged
kriszyp merged 16 commits into
mainfrom
named-ordering
May 12, 2026
Merged

Middleware ordering and routing#397
kriszyp merged 16 commits into
mainfrom
named-ordering

Conversation

@kriszyp

@kriszyp kriszyp commented Apr 23, 2026

Copy link
Copy Markdown
Member

Named middleware ordering and per-route middleware chains

Problem

Harper's HTTP middleware stack had two coarse ordering mechanisms: registration order and the runFirst flag (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/after dependency-based ordering (Server.ts, middlewareChain.ts)
Components can now declare before: 'name' or after: 'name' in their server.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. runFirst is deprecated but continues to work.

Automatic component naming (Scope.ts)
The scope.server proxy now injects the plugin's name (and its urlPath/host config) into every server.http/request/ws/upgrade call. 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: true is removed. Static now participates in normal ordering; its before: 'authentication' default (configurable) keeps the preferred static ? auth ? rest chain when they appear in that order in config.

Per-route middleware chains (middlewareChain.ts)
Components configured with urlPath or host get 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 transitive after dependencies from any route ? so if REST on /api declares after: 'authentication', auth is included in the /api chain 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 makeCallbackChain integration scenarios.

Addresses #395 and #416

Documentation

kriszyp and others added 4 commits April 20, 2026 19:03
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>
kriszyp and others added 4 commits April 29, 2026 05:53
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>
@kriszyp kriszyp changed the title Named ordering Middleware ordering and routing Apr 29, 2026
@kriszyp
kriszyp changed the base branch from pluginify-built-ins to main April 29, 2026 12:26
@kriszyp
kriszyp changed the base branch from main to pluginify-built-ins April 29, 2026 12:35
@kriszyp
kriszyp marked this pull request as ready for review April 29, 2026 12:36
@kriszyp
kriszyp requested a review from a team as a code owner April 29, 2026 12:36

@Ethan-Arrowood Ethan-Arrowood 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.

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?

Base automatically changed from pluginify-built-ins to main April 29, 2026 22:48
kriszyp and others added 2 commits May 5, 2026 22:33
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>
@claude

claude Bot commented May 6, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@heskew heskew 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.

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

Comment thread server/middlewareChain.ts
for (const entry of [...included]) {
if (entry.after) {
const dep = nameToEntry.get(entry.after);
if (dep && !included.has(dep)) {

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.

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. 🤖

Comment thread server/middlewareChain.ts
}

/**
* Returns true when `request` satisfies the route's host and urlPath constraints.

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.

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. 🤖

Comment thread server/Server.ts
}

export interface HttpOptions extends ServerOptions {
/** @deprecated Use `before` or `after` for explicit ordering instead */

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.

AI Review [Low]: UpgradeOptions and WebSocketOptions were not updated to include these new fields, leading to TS errors for those listener types. 🤖

Comment thread server/middlewareChain.ts
const successors: number[][] = Array.from({ length: n }, () => []);
const inDegree = new Int32Array(n);
const addEdge = (from: number, to: number) => {
successors[from].push(to);

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.

AI Review [Low]: Silent fallback on dependency cycles. Server.ts does not provide an onCycle callback, resulting in non-deterministic ordering if cycles exist. 🤖

kriszyp and others added 4 commits May 11, 2026 06:36
…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>
@socket-security

socket-security Bot commented May 11, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatedmqtt@​4.3.8 ⏵ 5.15.196 -210010089100

View full report

Comment thread server/Server.ts
securePort?: number;
runFirst?: boolean;
}
export interface UpgradeOptions extends ServerOptions {}

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.

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:

Suggested change
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>
Comment thread server/middlewareChain.ts Outdated
Comment on lines +227 to +234
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);
};

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.

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: socket and head are lost; upgrade listeners receive (request, callback) instead of (request, socket, head, callback).
  • WebSocket chains: ws is bound to the request parameter; matchesRoute(ws, route) fails because ws has no pathname; the default chain is called as defaultChain(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 cb1kenobi 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.

Gave this a test with my app and things seemed to good. Nice work!

@kriszyp
kriszyp merged commit b85eaec into main May 12, 2026
24 of 25 checks passed
@kriszyp
kriszyp deleted the named-ordering branch May 12, 2026 19:25
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.

4 participants