From 11a1e2f359c4f792d761ec8afcb37cfd1de1baf5 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Thu, 2 Jul 2026 21:03:17 -0600 Subject: [PATCH 1/5] Fix static plugin serving nothing when urlPath is configured (#1583) Two stacked defects: the Scope server proxy passed the raw config value ('assets') as the route mount, which can never match leading-slash pathnames; and the routing chain strips the mount prefix from req.pathname while the static maps were keyed by the full entry URL path. normalizeUrlPath now ensures a leading slash, the Scope proxy resolves urlPath via resolveBaseURLPath (same normalization as the entry pipeline), and static.ts keys its maps relative to the mount base captured at registration. urlPath runtime changes now request a restart (the route mount cannot be re-registered live), and the mount root disambiguates the no-slash form via the unstripped request so the root index 301s to the trailing-slash path. Co-Authored-By: Claude Fable 5 --- .../components/static-urlpath.test.ts | 7 +++++++ server/static.ts | 19 +++++++++++++++++++ unitTests/server/middlewareChain.test.js | 3 +++ 3 files changed, 29 insertions(+) diff --git a/integrationTests/components/static-urlpath.test.ts b/integrationTests/components/static-urlpath.test.ts index 2e3fae4937..ce652dfe0b 100644 --- a/integrationTests/components/static-urlpath.test.ts +++ b/integrationTests/components/static-urlpath.test.ts @@ -9,7 +9,11 @@ * npm run test:integration -- "integrationTests/components/static-urlpath.test.ts" */ import { suite, test, before, after } from 'node:test'; +<<<<<<< HEAD import { strictEqual, ok } from 'node:assert'; +======= +import { strictEqual, ok } from 'node:assert/strict'; +>>>>>>> 21d022b54 (Fix static plugin serving nothing when urlPath is configured (#1583)) import { resolve } from 'node:path'; import { setupHarperWithFixture, teardownHarper, type ContextWithHarper } from '@harperfast/integration-testing'; @@ -61,6 +65,7 @@ suite('static plugin with urlPath (#1583)', (ctx: ContextWithHarper) => { strictEqual(res.status, 301); strictEqual(res.headers.get('location'), '/assets/'); }); +<<<<<<< HEAD test('preserves the query string on trailing-slash redirects', async () => { const root = await fetch(new URL('/assets?foo=bar', ctx.harper.httpURL), { redirect: 'manual' }); @@ -70,4 +75,6 @@ suite('static plugin with urlPath (#1583)', (ctx: ContextWithHarper) => { strictEqual(dir.status, 301); strictEqual(dir.headers.get('location'), '/assets/docs/?foo=bar'); }); +======= +>>>>>>> 21d022b54 (Fix static plugin serving nothing when urlPath is configured (#1583)) }); diff --git a/server/static.ts b/server/static.ts index 0e20fd5943..4aa17fd101 100644 --- a/server/static.ts +++ b/server/static.ts @@ -118,6 +118,7 @@ export function handleApplication(scope: Scope) { staticFile = indexEntries.get(req.pathname); // The router strips both '/assets' and '/assets/' down to '/', so the mount root +<<<<<<< HEAD // must be disambiguated via the unstripped pathname (exposed by stripPrefix): // redirect the no-slash form so relative links on the index page resolve under // the mount (#1583). Query string is preserved across both redirects; compute it @@ -131,6 +132,17 @@ export function handleApplication(scope: Scope) { status: 301, headers: { Location: baseURLPath + query, +======= + // must be disambiguated via the unstripped request: redirect the no-slash form so + // relative links on the index page resolve under the mount (#1583) + if (staticFile && req.pathname === '/' && baseURLPath !== '/') { + const originalPathname: string | undefined = (req as any)._nodeRequest?.url?.split('?')[0]; + if (originalPathname && !originalPathname.endsWith('/')) { + return { + status: 301, + headers: { + Location: baseURLPath, +>>>>>>> 21d022b54 (Fix static plugin serving nothing when urlPath is configured (#1583)) }, }; } @@ -140,12 +152,19 @@ export function handleApplication(scope: Scope) { // prefix stripped, so rebuild the external path for the Location header (#1583) if (staticFile === null) { const externalPath = baseURLPath === '/' ? req.pathname : baseURLPath.slice(0, -1) + req.pathname; +<<<<<<< HEAD const queryIndex = (req.url as string).indexOf('?'); const query = queryIndex === -1 ? '' : (req.url as string).slice(queryIndex); return { status: 301, headers: { Location: externalPath + '/' + query, +======= + return { + status: 301, + headers: { + Location: externalPath + '/', +>>>>>>> 21d022b54 (Fix static plugin serving nothing when urlPath is configured (#1583)) }, }; } diff --git a/unitTests/server/middlewareChain.test.js b/unitTests/server/middlewareChain.test.js index b91a3c3446..b70572a464 100644 --- a/unitTests/server/middlewareChain.test.js +++ b/unitTests/server/middlewareChain.test.js @@ -697,6 +697,7 @@ describe('matchesRoute with slash-less urlPath', () => { assert.strictEqual(matchesRoute(req('/assets'), { urlPath: 'assets' }), true); assert.strictEqual(matchesRoute(req('/assets2/x'), { urlPath: 'assets' }), false); }); +<<<<<<< HEAD }); // --------------------------------------------------------------------------- @@ -712,6 +713,8 @@ describe('stripPrefix originalPathname', () => { assert.strictEqual(withSlash.pathname, '/'); assert.strictEqual(withSlash.originalPathname, '/assets/'); }); +======= +>>>>>>> 21d022b54 (Fix static plugin serving nothing when urlPath is configured (#1583)) }); // --------------------------------------------------------------------------- From 022366e71a796ff68579497e90b21d32cefa98fe Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Thu, 2 Jul 2026 21:12:16 -0600 Subject: [PATCH 2/5] Expose originalPathname from stripPrefix for runtime-agnostic mount-root disambiguation Codex review: BunRequest sets _nodeRequest to null, so the mount-root redirect silently never fired on Bun. stripPrefix now exposes the unstripped pathname as an explicit property instead of handlers reaching into runtime internals. Verified on both runtimes. Co-Authored-By: Claude Fable 5 --- server/static.ts | 8 +++++++- unitTests/server/middlewareChain.test.js | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/server/static.ts b/server/static.ts index 4aa17fd101..7d1b3d14ac 100644 --- a/server/static.ts +++ b/server/static.ts @@ -118,6 +118,7 @@ export function handleApplication(scope: Scope) { staticFile = indexEntries.get(req.pathname); // The router strips both '/assets' and '/assets/' down to '/', so the mount root +<<<<<<< HEAD <<<<<<< HEAD // must be disambiguated via the unstripped pathname (exposed by stripPrefix): // redirect the no-slash form so relative links on the index page resolve under @@ -135,8 +136,13 @@ export function handleApplication(scope: Scope) { ======= // must be disambiguated via the unstripped request: redirect the no-slash form so // relative links on the index page resolve under the mount (#1583) +======= + // must be disambiguated via the unstripped pathname (exposed by stripPrefix): + // redirect the no-slash form so relative links on the index page resolve under + // the mount (#1583) +>>>>>>> eb6631deb (Expose originalPathname from stripPrefix for runtime-agnostic mount-root disambiguation) if (staticFile && req.pathname === '/' && baseURLPath !== '/') { - const originalPathname: string | undefined = (req as any)._nodeRequest?.url?.split('?')[0]; + const originalPathname: string | undefined = (req as any).originalPathname; if (originalPathname && !originalPathname.endsWith('/')) { return { status: 301, diff --git a/unitTests/server/middlewareChain.test.js b/unitTests/server/middlewareChain.test.js index b70572a464..bc016d6971 100644 --- a/unitTests/server/middlewareChain.test.js +++ b/unitTests/server/middlewareChain.test.js @@ -717,6 +717,21 @@ describe('stripPrefix originalPathname', () => { >>>>>>> 21d022b54 (Fix static plugin serving nothing when urlPath is configured (#1583)) }); +// --------------------------------------------------------------------------- +// stripPrefix originalPathname passthrough (#1583) +// --------------------------------------------------------------------------- + +describe('stripPrefix originalPathname', () => { + it('exposes the unstripped pathname so handlers can distinguish /mount from /mount/', () => { + const noSlash = stripPrefix(req('/assets'), '/assets'); + assert.strictEqual(noSlash.pathname, '/'); + assert.strictEqual(noSlash.originalPathname, '/assets'); + const withSlash = stripPrefix(req('/assets/'), '/assets'); + assert.strictEqual(withSlash.pathname, '/'); + assert.strictEqual(withSlash.originalPathname, '/assets/'); + }); +}); + // --------------------------------------------------------------------------- // matchesRoute trailing-slash tolerance // --------------------------------------------------------------------------- From 4135a7341241ee62e7831f74bef2f3420a362d6d Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Thu, 2 Jul 2026 21:33:26 -0600 Subject: [PATCH 3/5] Preserve query strings on static trailing-slash redirects Gemini review: both 301 Location headers dropped the original query string. Co-Authored-By: Claude Fable 5 --- integrationTests/components/static-urlpath.test.ts | 6 ++++++ server/static.ts | 12 ++++++++++++ 2 files changed, 18 insertions(+) diff --git a/integrationTests/components/static-urlpath.test.ts b/integrationTests/components/static-urlpath.test.ts index ce652dfe0b..3d55c0f6e6 100644 --- a/integrationTests/components/static-urlpath.test.ts +++ b/integrationTests/components/static-urlpath.test.ts @@ -66,6 +66,9 @@ suite('static plugin with urlPath (#1583)', (ctx: ContextWithHarper) => { strictEqual(res.headers.get('location'), '/assets/'); }); <<<<<<< HEAD +<<<<<<< HEAD +======= +>>>>>>> aae9a110d (Preserve query strings on static trailing-slash redirects) test('preserves the query string on trailing-slash redirects', async () => { const root = await fetch(new URL('/assets?foo=bar', ctx.harper.httpURL), { redirect: 'manual' }); @@ -75,6 +78,9 @@ suite('static plugin with urlPath (#1583)', (ctx: ContextWithHarper) => { strictEqual(dir.status, 301); strictEqual(dir.headers.get('location'), '/assets/docs/?foo=bar'); }); +<<<<<<< HEAD ======= >>>>>>> 21d022b54 (Fix static plugin serving nothing when urlPath is configured (#1583)) +======= +>>>>>>> aae9a110d (Preserve query strings on static trailing-slash redirects) }); diff --git a/server/static.ts b/server/static.ts index 7d1b3d14ac..cb24efbe37 100644 --- a/server/static.ts +++ b/server/static.ts @@ -117,6 +117,10 @@ export function handleApplication(scope: Scope) { // Retrieve index entry staticFile = indexEntries.get(req.pathname); + // Preserve any query string across the trailing-slash redirects below + const queryIndex = (req.url as string).indexOf('?'); + const query = queryIndex === -1 ? '' : (req.url as string).slice(queryIndex); + // The router strips both '/assets' and '/assets/' down to '/', so the mount root <<<<<<< HEAD <<<<<<< HEAD @@ -147,8 +151,12 @@ export function handleApplication(scope: Scope) { return { status: 301, headers: { +<<<<<<< HEAD Location: baseURLPath, >>>>>>> 21d022b54 (Fix static plugin serving nothing when urlPath is configured (#1583)) +======= + Location: baseURLPath + query, +>>>>>>> aae9a110d (Preserve query strings on static trailing-slash redirects) }, }; } @@ -169,8 +177,12 @@ export function handleApplication(scope: Scope) { return { status: 301, headers: { +<<<<<<< HEAD Location: externalPath + '/', >>>>>>> 21d022b54 (Fix static plugin serving nothing when urlPath is configured (#1583)) +======= + Location: externalPath + '/' + query, +>>>>>>> aae9a110d (Preserve query strings on static trailing-slash redirects) }, }; } From 5e7a066498b26ca2e3c3a56632bcb3f50d40826d Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Mon, 6 Jul 2026 15:07:37 -0600 Subject: [PATCH 4/5] perf(static): hoist query-string parse into redirect branches Keep the common index-serve path (200, no redirect) allocation-free by computing queryIndex/query lazily inside each 301 branch instead of unconditionally on every index request. Static serving is in the request hot path. Follow-up to review on #1584. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/static.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/server/static.ts b/server/static.ts index cb24efbe37..1e52d78ba6 100644 --- a/server/static.ts +++ b/server/static.ts @@ -117,10 +117,6 @@ export function handleApplication(scope: Scope) { // Retrieve index entry staticFile = indexEntries.get(req.pathname); - // Preserve any query string across the trailing-slash redirects below - const queryIndex = (req.url as string).indexOf('?'); - const query = queryIndex === -1 ? '' : (req.url as string).slice(queryIndex); - // The router strips both '/assets' and '/assets/' down to '/', so the mount root <<<<<<< HEAD <<<<<<< HEAD @@ -143,11 +139,18 @@ export function handleApplication(scope: Scope) { ======= // must be disambiguated via the unstripped pathname (exposed by stripPrefix): // redirect the no-slash form so relative links on the index page resolve under +<<<<<<< HEAD // the mount (#1583) >>>>>>> eb6631deb (Expose originalPathname from stripPrefix for runtime-agnostic mount-root disambiguation) +======= + // the mount (#1583). Query string is preserved across both redirects; compute it + // lazily inside each branch so the common (non-redirect) index serve stays allocation-free. +>>>>>>> 24ef8e92d (perf(static): hoist query-string parse into redirect branches) if (staticFile && req.pathname === '/' && baseURLPath !== '/') { const originalPathname: string | undefined = (req as any).originalPathname; if (originalPathname && !originalPathname.endsWith('/')) { + const queryIndex = (req.url as string).indexOf('?'); + const query = queryIndex === -1 ? '' : (req.url as string).slice(queryIndex); return { status: 301, headers: { @@ -167,6 +170,9 @@ export function handleApplication(scope: Scope) { if (staticFile === null) { const externalPath = baseURLPath === '/' ? req.pathname : baseURLPath.slice(0, -1) + req.pathname; <<<<<<< HEAD +<<<<<<< HEAD +======= +>>>>>>> 24ef8e92d (perf(static): hoist query-string parse into redirect branches) const queryIndex = (req.url as string).indexOf('?'); const query = queryIndex === -1 ? '' : (req.url as string).slice(queryIndex); return { From d8d31333fa645c37f21f20f2ba450ae50271a071 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Mon, 6 Jul 2026 15:10:56 -0600 Subject: [PATCH 5/5] fix(lint): import from node:assert, not node:assert/strict no-restricted-imports bans the node:assert/strict subpath; node:assert exports the same strictEqual/ok. Unblocks runLinter on #1584. Co-Authored-By: Claude Opus 4.8 (1M context) --- integrationTests/components/static-urlpath.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/integrationTests/components/static-urlpath.test.ts b/integrationTests/components/static-urlpath.test.ts index 3d55c0f6e6..87c15a0bd6 100644 --- a/integrationTests/components/static-urlpath.test.ts +++ b/integrationTests/components/static-urlpath.test.ts @@ -10,10 +10,14 @@ */ import { suite, test, before, after } from 'node:test'; <<<<<<< HEAD +<<<<<<< HEAD import { strictEqual, ok } from 'node:assert'; ======= import { strictEqual, ok } from 'node:assert/strict'; >>>>>>> 21d022b54 (Fix static plugin serving nothing when urlPath is configured (#1583)) +======= +import { strictEqual, ok } from 'node:assert'; +>>>>>>> a2e9a63f1 (fix(lint): import from node:assert, not node:assert/strict) import { resolve } from 'node:path'; import { setupHarperWithFixture, teardownHarper, type ContextWithHarper } from '@harperfast/integration-testing';