Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/spotty-bees-shout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes a bug where the dev server stripped the configured `base` from URLs that only share a prefix with it. With `base: '/s'`, requests to `/src/...` were rewritten to `/rc/...` and failed, breaking those pages during development.
19 changes: 11 additions & 8 deletions packages/astro/src/vite-plugin-astro-server/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ import { writeHtmlResponse } from './response.js';
/**
* Outcome of the base-URL evaluation for a dev-server request.
*
* - **`rewrite`** — The request URL starts with the configured `base` path.
* Strip the base prefix so downstream handlers see a root-relative URL
* (e.g. `/docs/about` → `/about` when `base: '/docs'`).
* - **`rewrite`** — The request URL is the configured `base` path or lies
* under it at a path-segment boundary. Strip the base prefix so downstream
* handlers see a root-relative URL (e.g. `/docs/about` → `/about` when
* `base: '/docs'`).
* - **`not-found-subpath`** — The user navigated to `/` or `/index.html` but
* the project has a non-root `base`. Respond with a 404 explaining that the
* site lives under the base path, so the developer knows to update the URL.
Expand Down Expand Up @@ -52,10 +53,10 @@ export function resolveDevRoot(base: string, site?: string) {
/**
* Pure decision function for base-URL dev-server rewriting.
*
* Evaluates whether the incoming `url` starts with the project's `base` path
* and returns the action the middleware should take. The async `fs.stat` branch
* (checking `publicDir`) is represented as `check-public` and must be handled
* by the caller.
* Evaluates whether the incoming `url` is the project's `base` path or lies
* under it at a path-segment boundary, and returns the action the middleware
* should take. The async `fs.stat` branch (checking `publicDir`) is
* represented as `check-public` and must be handled by the caller.
*/
export function evaluateBaseRewrite(
url: string,
Expand All @@ -64,7 +65,9 @@ export function evaluateBaseRewrite(
devRoot: string,
devRootReplacement: string,
): BaseRewriteDecision {
if (pathname.startsWith(devRoot)) {
// Match only at path-segment boundaries so a base like `/s` doesn't
// swallow unrelated requests such as `/src/...` (same policy as #17701).
if (pathname === devRoot || pathname.startsWith(appendForwardSlash(devRoot))) {
let newUrl = url.replace(devRoot, devRootReplacement);
if (!newUrl.startsWith('/')) newUrl = prependForwardSlash(newUrl);
return { action: 'rewrite', newUrl };
Expand Down
49 changes: 49 additions & 0 deletions packages/astro/test/units/dev/base-rewrite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,55 @@ describe('evaluateBaseRewrite — rewrite', () => {
});
// #endregion

// #region evaluateBaseRewrite — path-segment boundaries
describe('evaluateBaseRewrite — path-segment boundaries', () => {
it('does not rewrite a URL whose first segment merely starts with the base', () => {
// base '/s' must not swallow '/src/...' (e.g. Vite virtual module requests
// emitted without the base by the dev server).
const result = evaluateBaseRewrite(
'/src/pages/index.astro?astro&type=style&index=0&lang.css',
'/src/pages/index.astro',
undefined,
'/s',
'',
);
assert.equal(result.action, 'check-public');
});

it('returns not-found for an HTML request whose first segment merely starts with the base', () => {
const result = evaluateBaseRewrite(
'/docs-archive/page',
'/docs-archive/page',
'text/html',
'/docs',
'',
);
assert.equal(result.action, 'not-found');
});

it('still rewrites the exact base match', () => {
const result = evaluateBaseRewrite('/s', '/s', undefined, '/s', '');
assert.equal(result.action, 'rewrite');
if (result.action === 'rewrite') {
assert.equal(result.newUrl, '/');
}
});

it('still rewrites URLs under the base, preserving query params', () => {
const result = evaluateBaseRewrite('/s/page?foo=bar', '/s/page', undefined, '/s', '');
assert.equal(result.action, 'rewrite');
if (result.action === 'rewrite') {
assert.equal(result.newUrl, '/page?foo=bar');
}
});

it('does not rewrite a prefix-colliding segment when the base has a trailing slash', () => {
const result = evaluateBaseRewrite('/docsX/page', '/docsX/page', undefined, '/docs/', '/');
assert.equal(result.action, 'check-public');
});
});
// #endregion

// #region evaluateBaseRewrite — not-found-subpath
describe('evaluateBaseRewrite — not-found-subpath', () => {
it('returns not-found-subpath for / when base is not /', () => {
Expand Down
Loading