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/fix-app-match-malformed-uri.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Prevents `App.match()` from throwing on request paths that contain an invalid percent-sequence.
5 changes: 5 additions & 0 deletions .changeset/fix-client-hmr-program-reload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes an issue where editing a client-side component (e.g. with `client:idle`, `client:load`, etc.) caused an unnecessary full program reload of the backend during development.
5 changes: 5 additions & 0 deletions .changeset/fix-endpoint-html-params.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes a bug where static file endpoints using `getStaticPaths` with `.html` in dynamic param values (e.g. `{ path: 'file.html' }`) would fail with a `NoMatchingStaticPathFound` error during build. The `.html` suffix is no longer incorrectly stripped from endpoint route pathnames.
5 changes: 5 additions & 0 deletions .changeset/lazy-jeans-brake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"astro": patch
---

Fix an issue where dynamic routes would return the string `[object Object]` instead of the expected content, in certain runtimes.
5 changes: 5 additions & 0 deletions .changeset/normalize-filename-case.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes styles being stripped when the project root is started with a path whose case differs from the actual filesystem case (e.g. running `astro dev` from `d:\dev\app` while the folder on disk is `D:\dev\app`).
6 changes: 6 additions & 0 deletions .changeset/satteri-gfm-math-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@astrojs/markdown-satteri': patch
'@astrojs/mdx': patch
---

Updates Sätteri processor to v0.8.0. See [its changelog](https://github.com/bruits/satteri/blob/main/packages/satteri/CHANGELOG.md#080--2026-06-03) for details on bugs fixed and features added.
33 changes: 24 additions & 9 deletions packages/astro/src/core/app/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,20 +261,35 @@ export abstract class BaseApp<P extends Pipeline = AppPipeline> {
}

/**
* Extracts the base-stripped, decoded pathname from a request.
* Used by adapters to compute the pathname for dev-mode route matching.
* Decodes a pathname with `decodeURI`, falling back to the raw pathname when it
* contains an invalid percent-sequence (e.g. `%C0%AF`, an overlong-UTF-8 encoding of
* `/` commonly sent by path-traversal scanners). A raw `decodeURI()` would throw
* `URIError: URI malformed`, and because `match()` runs before `render()` that error
* escapes the adapter's request handler as an uncaught exception (HTTP 500) that user
* middleware can't catch.
*/
public getPathnameFromRequest(request: Request): string {
const url = new URL(request.url);
const pathname = prependForwardSlash(this.removeBase(url.pathname));
private safeDecodeURI(pathname: string): string {
try {
return decodeURI(pathname);
} catch (e: any) {
this.adapterLogger.error(e.toString());
// Malformed request paths are expected client input (commonly from automated
// scanners) rather than a server fault, and this runs per-request on the hot
// path. Log at `debug` so it stays diagnosable without flooding error logs.
this.adapterLogger.debug(e.toString());
return pathname;
}
}

/**
* Extracts the base-stripped, decoded pathname from a request.
* Used by adapters to compute the pathname for dev-mode route matching.
*/
public getPathnameFromRequest(request: Request): string {
const url = new URL(request.url);
const pathname = prependForwardSlash(this.removeBase(url.pathname));
return this.safeDecodeURI(pathname);
}

/**
* Given a `Request`, it returns the `RouteData` that matches its `pathname`. By default, prerendered
* routes aren't returned, even if they are matched.
Expand All @@ -291,7 +306,7 @@ export abstract class BaseApp<P extends Pipeline = AppPipeline> {
if (!pathname) {
pathname = prependForwardSlash(this.removeBase(url.pathname));
}
const routeData = this.pipeline.matchRoute(decodeURI(pathname));
const routeData = this.pipeline.matchRoute(this.safeDecodeURI(pathname));
if (!routeData) return undefined;
if (allowPrerenderedRoutes) {
return routeData;
Expand All @@ -303,7 +318,7 @@ export abstract class BaseApp<P extends Pipeline = AppPipeline> {
// the same pattern should handle all other URLs.
if (routeData.prerender) {
if (routeData.params.length > 0) {
const allMatches = this.pipeline.matchAllRoutes(decodeURI(pathname));
const allMatches = this.pipeline.matchAllRoutes(this.safeDecodeURI(pathname));
return allMatches.find((r) => !r.prerender);
}
return undefined;
Expand Down Expand Up @@ -444,7 +459,7 @@ export abstract class BaseApp<P extends Pipeline = AppPipeline> {
if (!routeData) {
const domainPathname = this.computePathnameFromDomain(request);
if (domainPathname) {
routeData = this.pipeline.matchRoute(decodeURI(domainPathname));
routeData = this.pipeline.matchRoute(this.safeDecodeURI(domainPathname));
}
}
const resolvedOptions: ResolvedRenderOptions = {
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/core/errors/dev/vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ export async function getViteErrorPayload(err: ErrorWithMetadata): Promise<Astro
}

/**
* Transformer for `shiki`'s legacy `lineOptions`, allows to add classes to specific lines
* Transformer for `shiki`'s legacy `lineOptions`, allows adding classes to specific lines
* FROM: https://github.com/shikijs/shiki/blob/4a58472070a9a359a4deafec23bb576a73e24c6a/packages/transformers/src/transformers/compact-line-options.ts
* LICENSE: https://github.com/shikijs/shiki/blob/4a58472070a9a359a4deafec23bb576a73e24c6a/LICENSE
*/
Expand Down
13 changes: 10 additions & 3 deletions packages/astro/src/core/fetch/fetch-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -773,11 +773,18 @@ export class FetchState implements AstroFetchState {
*/
/**
* Strip `.html` / `/index.html` suffixes from the pathname so the
* rendering pipeline sees the canonical route path. Skipped when the
* matched route itself has an `.html` extension in its definition.
* rendering pipeline sees the canonical route path. Only applies to
* page routes where `.html` is framework-injected. Endpoint routes
* preserve `.html` because any such suffix is user-provided (e.g.
* from `getStaticPaths` params). Skipped when the matched route
* itself has an `.html` extension in its definition.
*/
#stripHtmlExtension(): void {
if (this.routeData && !routeHasHtmlExtension(this.routeData)) {
if (
this.routeData &&
this.routeData.type === 'page' &&
!routeHasHtmlExtension(this.routeData)
) {
this.pathname = this.pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, '');
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/core/logger/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ export class AstroLogger {
}

/**
* It calls the `flush` function of the provided destinatin, if it exists.
* It calls the `flush` function of the provided destination, if it exists.
*/
flush() {
if (this.options.destination.flush) {
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/core/routing/rewrite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ export function copyRequest(
signal: oldRequest.signal,
keepalive: oldRequest.keepalive,
// https://fetch.spec.whatwg.org/#dom-request-duplex
// @ts-expect-error It isn't part of the types, but undici accepts it and it allows to carry over the body to a new request
// @ts-expect-error It isn't part of the types, but undici accepts it and it allows carrying over the body to a new request
duplex: 'half',
},
});
Expand Down
4 changes: 3 additions & 1 deletion packages/astro/src/runtime/server/render/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,9 @@ export interface RendererFlusher {
}

export const isNode =
typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]';
typeof process !== 'undefined' &&
Object.prototype.toString.call(process) === '[object process]' &&
!(typeof navigator !== 'undefined' && navigator.userAgent === 'Cloudflare-Workers');
// @ts-expect-error: Deno is not part of the types.
export const isDeno = typeof Deno !== 'undefined';

Expand Down
10 changes: 10 additions & 0 deletions packages/astro/src/vite-plugin-hmr-reload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,16 @@ export default function hmrReload(): Plugin {
if (hasSkippedStyleModules) {
return [];
}

// If we processed modules but none were SSR-only (all were found in the
// client module graph), return an empty array to prevent Vite's default
// HMR propagation. Without this, Vite would propagate through the SSR
// module graph, find no HMR boundary (e.g. .astro files), and trigger
// a full-reload that causes unnecessary program reloads for the module
// runner. The client environment handles HMR for these modules natively.
if (modules.length > 0) {
return [];
}
},
},
};
Expand Down
15 changes: 14 additions & 1 deletion packages/astro/src/vite-plugin-utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,26 @@ export function normalizeFilename(filename: string, root: URL) {
// is imported via a TypeScript path alias and Vite produces a relative virtual module ID.
const url = new URL(filename, root);
filename = viteID(url);
} else if (filename.startsWith('/') && !commonAncestorPath(filename, fileURLToPath(root))) {
} else if (filename.startsWith('/') && !isPathInRoot(filename, fileURLToPath(root))) {
const url = new URL('.' + filename, root);
filename = viteID(url);
}
return removeLeadingForwardSlashWindows(filename);
}

/**
* Check whether `filename` lives under `rootPath`. Falls back to a case-insensitive
* comparison so that paths whose case differs from `rootPath` (e.g. a `d:\dev\foo`
* cwd versus a `D:\dev\foo` filesystem on Windows, or any case-insensitive macOS
* volume) are still recognized as project-internal absolute paths.
*/
function isPathInRoot(filename: string, rootPath: string) {
if (commonAncestorPath(filename, rootPath)) {
return true;
}
return commonAncestorPath(filename.toLowerCase(), rootPath.toLowerCase()) !== '';
}

const postfixRE = /[?#].*$/s;
export function cleanUrl(url: string): string {
return url.replace(postfixRE, '');
Expand Down
82 changes: 82 additions & 0 deletions packages/astro/test/css-path-case.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { after, before, describe, it } from 'node:test';
import * as cheerio from 'cheerio';
import { type DevServer, type Fixture, loadFixture } from './test-utils.ts';

/**
* Regression test for https://github.com/withastro/astro/issues/14013
*
* On case-insensitive filesystems (macOS, Windows) the dev server can be started
* from a project root whose case differs from the actual on-disk case (e.g.
* `d:\dev\app` vs `D:\dev\app`). `normalizeFilename` compares the configured
* `root` against Vite-resolved module ids via `commonAncestorPath`, which is
* case-sensitive. When the two disagree on case at the first path segment (a
* Windows drive letter, or the leading directory on macOS) `commonAncestorPath`
* returns `''`, so the absolute id is no longer recognized as project-internal
* and gets rewritten to a bogus path. That misses the compile-metadata cache and
* strips the component's scoped `<style>` from the page.
*
* To reproduce the discrepancy we flip the case of the first alphabetic
* character of the root path. On a case-insensitive filesystem the flipped path
* still resolves to the real fixture, while Vite resolves module ids with the
* canonical case — exactly the mismatch from the issue. On a case-sensitive
* filesystem (most Linux CI) the flipped path does not exist, so the suite is
* skipped.
*/
const realRoot = fileURLToPath(new URL('./fixtures/css-path-case/', import.meta.url));

/** Flip the case of the first ASCII letter — the macOS leading dir or Windows drive letter. */
function flipFirstLetterCase(p: string): string {
const i = p.search(/[a-zA-Z]/);
if (i === -1) return p;
const ch = p[i];
const flipped = ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase();
return p.slice(0, i) + flipped + p.slice(i + 1);
}

const caseMismatchedRoot = flipFirstLetterCase(realRoot);

// Detect a case-insensitive filesystem directly rather than checking the OS:
// the flipped-case path resolves to the real fixture only when the filesystem
// ignores case (macOS, Windows). This is the precondition the test needs and is
// more accurate than an OS check (macOS is case-insensitive too, and case
// sensitivity can vary per-volume/per-directory on both macOS and Windows).
const isCaseInsensitiveFs = caseMismatchedRoot !== realRoot && fs.existsSync(caseMismatchedRoot);

describe('CSS scoped styles with a case-mismatched project root', {
skip: !isCaseInsensitiveFs,
}, () => {
let fixture: Fixture;
let devServer: DevServer;
let $: cheerio.CheerioAPI;

before(async () => {
fixture = await loadFixture({ root: caseMismatchedRoot });
devServer = await fixture.startDevServer();
const html = await fixture.fetch('/').then((res) => res.text());
$ = cheerio.load(html);
});

after(async () => {
await devServer?.stop();
});

it('applies the scope to the element', () => {
const h1 = $('h1');
const scopedAttribute = Object.keys(h1[0]?.attribs ?? {}).find((key) =>
/^data-astro-cid-/.test(key),
);
assert.ok(scopedAttribute, 'expected the <h1> to carry a data-astro-cid-* scope attribute');
});

it('injects the scoped style into the page (issue #14013)', () => {
const injectedStyles = $('style').text().replace(/\s/g, '');
assert.equal(
injectedStyles.includes('color:rgb(255,165,0)'),
true,
'expected the scoped <style> to be injected even though the root case differs from disk',
);
});
});
8 changes: 8 additions & 0 deletions packages/astro/test/fixtures/css-path-case/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@test/css-path-case",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}
17 changes: 17 additions & 0 deletions packages/astro/test/fixtures/css-path-case/src/pages/index.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
---

<html>
<head>
<title>Case Test</title>
</head>
<body>
<h1>Hello world</h1>
</body>
</html>

<style>
h1 {
color: rgb(255, 165, 0);
}
</style>
68 changes: 68 additions & 0 deletions packages/astro/test/units/app/malformed-uri.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { App } from '../../../dist/core/app/app.js';
import { parseRoute } from '../../../dist/core/routing/parse-route.js';
import { createComponent, render } from '../../../dist/runtime/server/index.js';
import { createManifest, createRouteInfo } from './test-helpers.ts';

/**
* Tests that a request path containing an invalid percent-sequence (one that is not
* valid UTF-8, e.g. `%C0%AF`) does not crash route matching.
*
* `App.match()` decodes the pathname with `decodeURI()`, which throws
* `URIError: URI malformed` on such input. Because matching happens before
* `App.render()`, that error used to escape the adapter request handler as an
* uncaught exception (HTTP 500) that user middleware could not catch. These paths
* are extremely common from automated path-traversal / `.env` scanners.
*/

const routeOptions: Parameters<typeof parseRoute>[1] = {
config: { base: '/', trailingSlash: 'ignore' },
pageExtensions: [],
} as any;

const indexRouteData = parseRoute('index.astro', routeOptions, {
component: 'src/pages/index.astro',
});

const page = createComponent((_result: any, _props: any, _slots: any) => {
return render`<h1>Page</h1>`;
});

const pageModule = async () => ({
page: async () => ({
default: page,
}),
});

const pageMap = new Map([[indexRouteData.component, pageModule]]);

const app = new App(
createManifest({
routes: [createRouteInfo(indexRouteData)],
pageMap: pageMap as any,
}) as any,
);

describe('Malformed URI handling in App.match', () => {
it('match() does not throw on an invalid percent-sequence', () => {
const request = new Request('http://example.com/%C0%AF');
assert.doesNotThrow(() => app.match(request));
assert.equal(app.match(request), undefined, 'no route should match a malformed path');
});

it('render() returns a 404 for a malformed percent-sequence', async () => {
const request = new Request('http://example.com/%C0%AF');
const response = await app.render(request);
assert.equal(
response.status,
404,
'a malformed path must resolve to a normal 404, not an uncaught 500',
);
});

it('valid routes still match', () => {
const request = new Request('http://example.com/');
assert.ok(app.match(request), '/ should still match');
});
});
Loading
Loading