- From content layers to server islands, click to learn more about the new features and - improvements in Astro 5.0 + Redesigned dev server, fonts, live collections, built-in CSP support, and more! Click to + explore Astro 6.0's new features.
diff --git a/packages/astro/src/actions/runtime/server.ts b/packages/astro/src/actions/runtime/server.ts index 5daeaf803dcc..635a76c348d7 100644 --- a/packages/astro/src/actions/runtime/server.ts +++ b/packages/astro/src/actions/runtime/server.ts @@ -10,6 +10,7 @@ import { } from '../../core/errors/errors-data.js'; import { AstroError } from '../../core/errors/errors.js'; import { removeTrailingForwardSlash } from '../../core/path.js'; +import { BodySizeLimitError, readBodyWithLimit } from '../../core/request-body.js'; import type { APIContext } from '../../types/public/index.js'; import { ACTION_QUERY_PARAMS, ACTION_RPC_ROUTE_PATTERN } from '../consts.js'; import { @@ -267,26 +268,36 @@ async function parseRequestBody(request: Request, bodySizeLimit: number) { message: `Request body exceeds ${bodySizeLimit} bytes`, }); } - if (hasContentType(contentType, formContentTypes)) { - if (!hasContentLength) { - const body = await readRequestBodyWithLimit(request.clone(), bodySizeLimit); - const formRequest = new Request(request.url, { - method: request.method, - headers: request.headers, - body: toArrayBuffer(body), - }); - return await formRequest.formData(); + try { + if (hasContentType(contentType, formContentTypes)) { + if (!hasContentLength) { + const body = await readBodyWithLimit(request.clone(), bodySizeLimit); + const formRequest = new Request(request.url, { + method: request.method, + headers: request.headers, + body: toArrayBuffer(body), + }); + return await formRequest.formData(); + } + return await request.clone().formData(); } - return await request.clone().formData(); - } - if (hasContentType(contentType, ['application/json'])) { - if (contentLength === 0) return undefined; - if (!hasContentLength) { - const body = await readRequestBodyWithLimit(request.clone(), bodySizeLimit); - if (body.byteLength === 0) return undefined; - return JSON.parse(new TextDecoder().decode(body)); + if (hasContentType(contentType, ['application/json'])) { + if (contentLength === 0) return undefined; + if (!hasContentLength) { + const body = await readBodyWithLimit(request.clone(), bodySizeLimit); + if (body.byteLength === 0) return undefined; + return JSON.parse(new TextDecoder().decode(body)); + } + return await request.clone().json(); } - return await request.clone().json(); + } catch (e) { + if (e instanceof BodySizeLimitError) { + throw new ActionError({ + code: 'CONTENT_TOO_LARGE', + message: `Request body exceeds ${bodySizeLimit} bytes`, + }); + } + throw e; } throw new TypeError('Unsupported content type'); } @@ -471,34 +482,6 @@ export function serializeActionResult(res: SafeResult/old'));
+ assert.ok(bodyText.includes('to /new'));
+ });
+
+ it('omits "from" text when not provided', () => {
+ const html = redirectTemplate({
+ status: 301,
+ absoluteLocation: 'https://example.com/new',
+ relativeLocation: '/new',
+ });
+
+ const $ = cheerio.load(html);
+ const bodyText = $('body').html();
+ assert.ok(!bodyText.includes('from '));
+ assert.ok(bodyText.includes('to /new'));
+ });
+
+ it('handles special characters in URLs', () => {
+ const html = redirectTemplate({
+ status: 301,
+ absoluteLocation: 'https://example.com/page?foo=bar&baz=qux',
+ relativeLocation: '/page?foo=bar&baz=qux',
+ });
+
+ const $ = cheerio.load(html);
+
+ // Title should show the URL as-is
+ assert.equal($('title').text(), 'Redirecting to: /page?foo=bar&baz=qux');
+
+ // Meta refresh should preserve the URL structure
+ const metaRefresh = $('meta[http-equiv="refresh"]');
+ assert.equal(metaRefresh.attr('content'), '0;url=/page?foo=bar&baz=qux');
+
+ // Link href should be properly escaped
+ const link = $('body a');
+ assert.equal(link.attr('href'), '/page?foo=bar&baz=qux');
+ });
+
+ it('handles external URLs in relative location', () => {
+ const html = redirectTemplate({
+ status: 301,
+ absoluteLocation: 'https://external.com/',
+ relativeLocation: 'https://external.com/',
+ });
+
+ const $ = cheerio.load(html);
+
+ // Should use the external URL in all places
+ assert.equal($('title').text(), 'Redirecting to: https://external.com/');
+ assert.equal($('meta[http-equiv="refresh"]').attr('content'), '0;url=https://external.com/');
+ assert.equal($('link[rel="canonical"]').attr('href'), 'https://external.com/');
+ assert.equal($('body a').attr('href'), 'https://external.com/');
+ });
+
+ it('handles URL object for absoluteLocation', () => {
+ const html = redirectTemplate({
+ status: 301,
+ absoluteLocation: new URL('https://example.com/page'),
+ relativeLocation: '/page',
+ });
+
+ const $ = cheerio.load(html);
+
+ // Should convert URL object to string
+ assert.equal($('link[rel="canonical"]').attr('href'), 'https://example.com/page');
+ });
+});
diff --git a/packages/astro/test/units/routing/api-context.test.js b/packages/astro/test/units/routing/api-context.test.js
index 027a70f8bf13..7a7077c5d1f7 100644
--- a/packages/astro/test/units/routing/api-context.test.js
+++ b/packages/astro/test/units/routing/api-context.test.js
@@ -3,27 +3,57 @@ import { describe, it } from 'node:test';
import { createContext } from '../../../dist/core/middleware/index.js';
describe('createAPIContext', () => {
- it('should return the clientAddress', () => {
- const request = new Request('http://example.com', {
- headers: {
- 'x-forwarded-for': '192.0.2.43, 172.16.58.3',
- },
- });
+ it('should return the clientAddress when explicitly provided', () => {
+ const request = new Request('http://example.com');
const context = createContext({
request,
+ clientAddress: '192.0.2.43',
});
assert.equal(context.clientAddress, '192.0.2.43');
});
- it('should return the correct locals', () => {
+ it('should throw when clientAddress is not provided', () => {
+ const request = new Request('http://example.com');
+
+ const context = createContext({
+ request,
+ });
+
+ assert.throws(
+ () => context.clientAddress,
+ (err) => {
+ assert.equal(err.name, 'StaticClientAddressNotAvailable');
+ return true;
+ },
+ );
+ });
+
+ it('should not read clientAddress from x-forwarded-for header', () => {
const request = new Request('http://example.com', {
headers: {
'x-forwarded-for': '192.0.2.43, 172.16.58.3',
},
});
+ const context = createContext({
+ request,
+ });
+
+ // Should throw instead of reading from the header
+ assert.throws(
+ () => context.clientAddress,
+ (err) => {
+ assert.equal(err.name, 'StaticClientAddressNotAvailable');
+ return true;
+ },
+ );
+ });
+
+ it('should return the correct locals', () => {
+ const request = new Request('http://example.com');
+
const context = createContext({
request,
locals: {
diff --git a/packages/astro/test/units/routing/getstaticpaths-cache.test.js b/packages/astro/test/units/routing/getstaticpaths-cache.test.js
new file mode 100644
index 000000000000..7b747328f708
--- /dev/null
+++ b/packages/astro/test/units/routing/getstaticpaths-cache.test.js
@@ -0,0 +1,125 @@
+import assert from 'node:assert/strict';
+import { describe, it, before, beforeEach } from 'node:test';
+import { Logger } from '../../../dist/core/logger/core.js';
+import { RouteCache, callGetStaticPaths } from '../../../dist/core/render/route-cache.js';
+import { dynamicPart, makeRoute } from './test-helpers.js';
+
+describe('getStaticPaths caching behavior', () => {
+ let routeCache;
+ let logger;
+ let callCount;
+
+ before(() => {
+ logger = new Logger({ dest: 'memory', level: 'error' });
+ });
+
+ beforeEach(() => {
+ routeCache = new RouteCache(logger, 'production');
+ callCount = 0;
+ });
+
+ it('only calls getStaticPaths once and caches the result', async () => {
+ const route = makeRoute({
+ segments: [[dynamicPart('param')]],
+ trailingSlash: 'never',
+ route: '/[param]',
+ pathname: undefined,
+ type: 'page',
+ prerender: true,
+ });
+
+ const mod = {
+ default: () => {},
+ getStaticPaths: async () => {
+ callCount++;
+ return [{ params: { param: 'a' } }, { params: { param: 'b' } }, { params: { param: 'c' } }];
+ },
+ };
+
+ // First call should execute getStaticPaths
+ const result1 = await callGetStaticPaths({
+ mod,
+ route,
+ routeCache,
+ ssr: false,
+ base: '/',
+ trailingSlash: 'never',
+ });
+
+ assert.equal(callCount, 1, 'getStaticPaths should be called once');
+ assert.equal(result1.length, 3, 'should return all paths');
+
+ // Second call should use cache
+ const result2 = await callGetStaticPaths({
+ mod,
+ route,
+ routeCache,
+ ssr: false,
+ base: '/',
+ trailingSlash: 'never',
+ });
+
+ assert.equal(callCount, 1, 'getStaticPaths should not be called again');
+ assert.equal(result2.length, 3, 'should return cached paths');
+ assert.deepEqual(result1, result2, 'cached result should match original');
+
+ // Third call should also use cache
+ const result3 = await callGetStaticPaths({
+ mod,
+ route,
+ routeCache,
+ ssr: false,
+ base: '/',
+ trailingSlash: 'never',
+ });
+
+ assert.equal(callCount, 1, 'getStaticPaths should still not be called');
+ assert.equal(result3.length, 3, 'should return cached paths');
+ });
+
+ it('clears cache when clearAll is called', async () => {
+ const route = makeRoute({
+ segments: [[dynamicPart('test')]],
+ trailingSlash: 'never',
+ route: '/[test]',
+ pathname: undefined,
+ type: 'page',
+ prerender: true,
+ });
+
+ const mod = {
+ default: () => {},
+ getStaticPaths: async () => {
+ callCount++;
+ return [{ params: { test: 'value' } }];
+ },
+ };
+
+ // First call
+ await callGetStaticPaths({
+ mod,
+ route,
+ routeCache,
+ ssr: false,
+ base: '/',
+ trailingSlash: 'never',
+ });
+
+ assert.equal(callCount, 1, 'getStaticPaths called once');
+
+ // Clear cache
+ routeCache.clearAll();
+
+ // Second call after clearing should call getStaticPaths again
+ await callGetStaticPaths({
+ mod,
+ route,
+ routeCache,
+ ssr: false,
+ base: '/',
+ trailingSlash: 'never',
+ });
+
+ assert.equal(callCount, 2, 'getStaticPaths called again after cache clear');
+ });
+});
diff --git a/packages/astro/test/units/routing/params-validation.test.js b/packages/astro/test/units/routing/params-validation.test.js
new file mode 100644
index 000000000000..83567961e2df
--- /dev/null
+++ b/packages/astro/test/units/routing/params-validation.test.js
@@ -0,0 +1,80 @@
+import assert from 'node:assert/strict';
+import { describe, it, before } from 'node:test';
+import { Logger } from '../../../dist/core/logger/core.js';
+import { RouteCache, callGetStaticPaths } from '../../../dist/core/render/route-cache.js';
+import { makeRoute } from './test-helpers.js';
+
+describe('getStaticPaths param validation', () => {
+ let routeCache;
+ let logger;
+
+ before(() => {
+ logger = new Logger({ dest: 'memory', level: 'error' });
+ routeCache = new RouteCache(logger, 'production');
+ });
+
+ // Create a route that uses rest params (spread) to allow undefined
+ const route = makeRoute({
+ segments: [[{ dynamic: true, content: 'testParam', spread: true }]],
+ trailingSlash: 'never',
+ route: '/[...testParam]',
+ pathname: undefined,
+ type: 'page',
+ prerender: true,
+ });
+
+ const paramTestCases = [
+ // Valid types
+ { type: 'string', value: 'foo', shouldPass: true },
+ { type: 'undefined', value: undefined, shouldPass: true },
+
+ // Invalid types
+ { type: 'number', value: 123, shouldPass: false },
+ { type: 'boolean', value: false, shouldPass: false },
+ { type: 'array', value: [1, 2, 3], shouldPass: false },
+ { type: 'null', value: null, shouldPass: false },
+ { type: 'object', value: { a: 1 }, shouldPass: false },
+ { type: 'bigint', value: BigInt(123), shouldPass: false },
+ { type: 'function', value: setTimeout, shouldPass: false },
+ ];
+
+ for (const { type, value, shouldPass } of paramTestCases) {
+ it(`${shouldPass ? 'accepts' : 'rejects'} param type ${type}`, async () => {
+ // Clear route cache before each test to ensure isolation
+ routeCache.clearAll();
+
+ const mod = {
+ default: () => {},
+ getStaticPaths: async () => [
+ {
+ params: { testParam: value },
+ },
+ ],
+ };
+
+ try {
+ await callGetStaticPaths({
+ mod,
+ route,
+ routeCache,
+ ssr: false,
+ base: '/',
+ trailingSlash: 'never',
+ });
+
+ if (!shouldPass) {
+ assert.fail(`Expected validation error for param type ${type}`);
+ }
+ } catch (err) {
+ if (shouldPass) {
+ throw err;
+ }
+
+ assert.equal(err.name, 'GetStaticPathsInvalidRouteParam');
+ // Arrays report as 'object' in typeof, so adjust the check
+ const expectedType = type === 'array' ? 'object' : type;
+ assert.match(err.message, new RegExp(expectedType));
+ }
+ });
+ }
+});
diff --git a/packages/astro/test/units/server-islands/endpoint.test.js b/packages/astro/test/units/server-islands/endpoint.test.js
index a4868a77ce41..63e9d9f4dbd4 100644
--- a/packages/astro/test/units/server-islands/endpoint.test.js
+++ b/packages/astro/test/units/server-islands/endpoint.test.js
@@ -149,6 +149,78 @@ describe('getRequestData', () => {
});
// #endregion
+ // #region Body size limiting
+ describe('POST body size limiting', () => {
+ it('returns 413 when POST body exceeds the configured limit', async () => {
+ const limit = 100; // 100 bytes
+ // Create a body larger than the limit
+ const largeBody = JSON.stringify({
+ encryptedComponentExport: 'x'.repeat(200),
+ encryptedProps: '',
+ encryptedSlots: '',
+ });
+ const req = new Request('http://localhost/_server-islands/Island', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: largeBody,
+ });
+ const result = await getRequestData(req, limit);
+ assert.ok(result instanceof Response, 'should return a Response');
+ assert.equal(result.status, 413);
+ });
+
+ it('returns 413 when Content-Length header exceeds the configured limit', async () => {
+ const limit = 100; // 100 bytes
+ const smallBody = JSON.stringify({
+ encryptedComponentExport: 'enc',
+ encryptedProps: '',
+ encryptedSlots: '',
+ });
+ const req = new Request('http://localhost/_server-islands/Island', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Content-Length': '999999',
+ },
+ body: smallBody,
+ });
+ const result = await getRequestData(req, limit);
+ assert.ok(result instanceof Response, 'should return a Response');
+ assert.equal(result.status, 413);
+ });
+
+ it('accepts POST body within the configured limit', async () => {
+ const limit = 10000; // 10KB
+ const body = {
+ encryptedComponentExport: 'encExport',
+ encryptedProps: 'encProps',
+ encryptedSlots: 'encSlots',
+ };
+ const req = new Request('http://localhost/_server-islands/Island', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ const result = await getRequestData(req, limit);
+ assert.ok(!(result instanceof Response), 'should not return a Response');
+ assert.equal(result.encryptedComponentExport, 'encExport');
+ });
+
+ it('uses default limit when no limit is specified', async () => {
+ // This should work fine with the default 1MB limit
+ const body = {
+ encryptedComponentExport: 'encExport',
+ encryptedProps: 'encProps',
+ encryptedSlots: 'encSlots',
+ };
+ const req = makePostRequest(body);
+ const result = await getRequestData(req);
+ assert.ok(!(result instanceof Response), 'should not return a Response');
+ assert.equal(result.encryptedComponentExport, 'encExport');
+ });
+ });
+ // #endregion
+
// #region Unsupported HTTP methods
describe('unsupported HTTP methods', () => {
for (const method of ['PUT', 'DELETE', 'PATCH', 'HEAD']) {
diff --git a/packages/astro/test/units/test-utils.js b/packages/astro/test/units/test-utils.js
index bc7b72b721ad..b1b42dd0d898 100644
--- a/packages/astro/test/units/test-utils.js
+++ b/packages/astro/test/units/test-utils.js
@@ -94,6 +94,9 @@ function buffersToString(buffers) {
}
/**
+ * Creates a basic Pipeline instance for testing.
+ * For mock utilities like createMockRenderContext, see mocks.js
+ *
* @param {Partial} options
* @returns {Pipeline}
*/
diff --git a/packages/astro/tsconfigs/base.json b/packages/astro/tsconfigs/base.json
index 1c667f933a8b..55adf57bb5e4 100644
--- a/packages/astro/tsconfigs/base.json
+++ b/packages/astro/tsconfigs/base.json
@@ -26,9 +26,7 @@
// Allow JavaScript files to be imported
"allowJs": true,
// Allow JSX files (or files that are internally considered JSX, like Astro files) to be imported inside `.js` and `.ts` files.
- "jsx": "preserve",
- "types": ["node"],
- "libReplacement": false
+ "jsx": "preserve"
},
"exclude": ["${configDir}/dist"],
"include": ["${configDir}/.astro/types.d.ts", "${configDir}/**/*"]
diff --git a/packages/integrations/netlify/src/index.ts b/packages/integrations/netlify/src/index.ts
index 652d45fc4107..947adf62c2a8 100644
--- a/packages/integrations/netlify/src/index.ts
+++ b/packages/integrations/netlify/src/index.ts
@@ -456,7 +456,8 @@ export default function netlifyIntegration(
const ctx = createContext({
request,
params: {},
- locals: { netlify: { context } }
+ locals: { netlify: { context } },
+ clientAddress: context.ip,
});
// https://docs.netlify.com/edge-functions/api/#return-a-rewrite
ctx.rewrite = (target) => {
diff --git a/packages/integrations/vercel/src/serverless/middleware.ts b/packages/integrations/vercel/src/serverless/middleware.ts
index 46ccd9d74743..0f215ee84aa4 100644
--- a/packages/integrations/vercel/src/serverless/middleware.ts
+++ b/packages/integrations/vercel/src/serverless/middleware.ts
@@ -119,7 +119,8 @@ import { createContext, trySerializeLocals } from 'astro/middleware';
export default async function middleware(request, context) {
const ctx = createContext({
request,
- params: {}
+ params: {},
+ clientAddress: request.headers.get('x-real-ip') || undefined,
});
Object.assign(ctx.locals, { vercel: { edge: context }, ...${handlerTemplateCall} });
const { origin } = new URL(request.url);