Summary
The plugin hands Next.js the raw Node request instead of the Harper Request, so it never sees the mount-relative path Harper computed. An app mounted at a urlPath therefore gives Next the un-stripped URL, which Next cannot route.
What happens
Harper's router already does the right thing. server/middlewareChain.ts builds a separate middleware chain per urlPath/host, and when a mounted route matches it wraps the request with stripPrefix(request, route.urlPath) — a Proxy that rewrites pathname and url (keeping the original available as originalPathname) — then dispatches only that route's chain.
The plugin then reaches past that Proxy to the object underneath:
// src/plugin.ts
requestHandler(request._nodeRequest, request._nodeResponse, urlParse(request._nodeRequest.url, true))
_nodeRequest is the underlying Node IncomingMessage. The Proxy rewrote request.url, not request._nodeRequest.url. So for an app mounted at /foo, a request to /foo/file.html reaches Next as /foo/file.html when Harper had already resolved it to /file.html.
Suggested fix
Request.withNodeAdapter() exists for exactly this. It proxies _nodeRequest but overrides method, url and headers with the current Request's values — its docstring notes these "may have been modified by middleware" — and gives back a ServerResponse that captures status/headers/body:
return request.withNodeAdapter((req, res) => requestHandler(req, res, urlParse(req.url, true)));
Two things to carry across from the adapter's contract:
- It resolves to
{ status, headers, body } where body is a PassThrough. The docstring flags that an error listener must be attached before the body is consumed, or a connection reset after headers are sent throws an uncaught exception.
- The upgrade handler (
/_next/webpack-hmr) uses _nodeRequest too. That is the upgrade path rather than the HTTP path, so it needs its own assessment rather than the same change applied blindly.
Not sufficient on its own
Fixing the inbound path makes Next route correctly under a mount, but Next also generates URLs — page links and especially /_next/* asset paths. Those would still be emitted at the root and 404 outside the mount. Emitting prefixed URLs requires basePath, which Next bakes in at build time.
So the complete story for serving a Next app under a urlPath is:
- this fix, so inbound requests are mount-relative; and
- the app built with
basePath matching its mount.
Worth deciding whether the plugin should require/inject basePath when a urlPath is configured, or fail loudly when they disagree — silently serving an app whose assets all 404 is the worst of the three.
Why it matters now
This is the blocker for hosting multiple Next apps on one Harper instance via urlPath mounting, which is the "simpler apps" tier of the application-isolation work (HarperFast/harper#642, and the thread-isolation design that came out of it). Today a single Next app at the root works; two apps under different urlPaths do not.
🤖 Filed by Claude on behalf of Kris
Summary
The plugin hands Next.js the raw Node request instead of the Harper
Request, so it never sees the mount-relative path Harper computed. An app mounted at aurlPaththerefore gives Next the un-stripped URL, which Next cannot route.What happens
Harper's router already does the right thing.
server/middlewareChain.tsbuilds a separate middleware chain perurlPath/host, and when a mounted route matches it wraps the request withstripPrefix(request, route.urlPath)— a Proxy that rewritespathnameandurl(keeping the original available asoriginalPathname) — then dispatches only that route's chain.The plugin then reaches past that Proxy to the object underneath:
_nodeRequestis the underlying NodeIncomingMessage. The Proxy rewroterequest.url, notrequest._nodeRequest.url. So for an app mounted at/foo, a request to/foo/file.htmlreaches Next as/foo/file.htmlwhen Harper had already resolved it to/file.html.Suggested fix
Request.withNodeAdapter()exists for exactly this. It proxies_nodeRequestbut overridesmethod,urlandheaderswith the current Request's values — its docstring notes these "may have been modified by middleware" — and gives back aServerResponsethat captures status/headers/body:Two things to carry across from the adapter's contract:
{ status, headers, body }wherebodyis aPassThrough. The docstring flags that anerrorlistener must be attached before the body is consumed, or a connection reset after headers are sent throws an uncaught exception./_next/webpack-hmr) uses_nodeRequesttoo. That is the upgrade path rather than the HTTP path, so it needs its own assessment rather than the same change applied blindly.Not sufficient on its own
Fixing the inbound path makes Next route correctly under a mount, but Next also generates URLs — page links and especially
/_next/*asset paths. Those would still be emitted at the root and 404 outside the mount. Emitting prefixed URLs requiresbasePath, which Next bakes in at build time.So the complete story for serving a Next app under a
urlPathis:basePathmatching its mount.Worth deciding whether the plugin should require/inject
basePathwhen aurlPathis configured, or fail loudly when they disagree — silently serving an app whose assets all 404 is the worst of the three.Why it matters now
This is the blocker for hosting multiple Next apps on one Harper instance via
urlPathmounting, which is the "simpler apps" tier of the application-isolation work (HarperFast/harper#642, and the thread-isolation design that came out of it). Today a single Next app at the root works; two apps under differenturlPaths do not.🤖 Filed by Claude on behalf of Kris