diff --git a/.agents/skills/astro-developer/SKILL.md b/.agents/skills/astro-developer/SKILL.md index f82e740bc928..6c47483031f3 100644 --- a/.agents/skills/astro-developer/SKILL.md +++ b/.agents/skills/astro-developer/SKILL.md @@ -17,6 +17,7 @@ Context-loading skill for AI agents and developers working in the Astro monorepo | Fixing a bug | [debugging.md](debugging.md) | [architecture.md](architecture.md) | | Writing/fixing tests | [testing.md](testing.md) | [constraints.md](constraints.md) | | Creating an integration | Explore `packages/integrations/` for examples | [testing.md](testing.md) | +| Creating/updating a PR | [pull-requests.md](pull-requests.md) | [testing.md](testing.md) | | Understanding architecture | [architecture.md](architecture.md) | - | | Dealing with errors | [debugging.md](debugging.md), [constraints.md](constraints.md) | [testing.md](testing.md) | | Understanding constraints | [constraints.md](constraints.md) | [architecture.md](architecture.md) | diff --git a/.agents/skills/astro-developer/pull-requests.md b/.agents/skills/astro-developer/pull-requests.md new file mode 100644 index 000000000000..5a025b6d6f05 --- /dev/null +++ b/.agents/skills/astro-developer/pull-requests.md @@ -0,0 +1,164 @@ +# Pull Request Guide + +How to write Astro pull request descriptions that help reviewers quickly understand intent, behavior changes, and validation. + +## Core Principle + +Describe the **change**, **how it works**, and **why it matters**. + +- `Changes` explains what the fix/feature does. +- `Testing` explains how the behavior was validated. +- `Docs` explains whether user-facing docs changes are needed. + +Do not use PR sections as a task log. + +## Brevity and Depth + +Be concise by default, detailed where needed. + +- Keep simple changes simple: 1 short bullet can be enough. +- Add detail only when it helps reviewer understanding (complex logic, non-obvious tradeoffs, edge cases). +- Prefer 2-4 bullets per section for typical PRs. +- Avoid repeating the same point across `Changes`, `Testing`, and `Docs`. + +Rule of thumb: include the minimum context needed for a reviewer to understand and trust the change. + +## PR Title + +Use a human, reviewer-friendly title. + +- Describe the outcome in plain language. +- Keep it concise and specific. +- Prefer phrasing a person would naturally write in a review queue. + +Do not use: + +- Conventional commit prefixes in PR titles (`fix:`, `feat:`, `docs:`, etc.) +- Scope tags in commit format (`fix(astro): ...`) + +Examples: + +- Bad: `fix(cloudflare): surface prerenderer body details` +- Good: `Surface Cloudflare prerenderer error details during build` + +## Section-by-Section Rules + +### Changes + +Use this section for the core code change: behavior, implementation approach, and impact. + +Length guidance: + +- Simple fix: 1-2 concise bullets. +- Complex change: add implementation detail, but keep each bullet focused on one idea. + +Good content: + +- What now works that did not work before +- How the fix/feature works (at a reviewer-useful level) +- What errors/messages/outputs changed +- What reliability/performance/compatibility behavior changed + +Do not include: + +- "Added test" or "updated fixture" (belongs in `Testing`) +- "Added changeset" (baseline required and CI-enforced; not useful PR context) +- Internal process notes that do not change behavior + +### Testing + +Use this section to explain validation quality. + +Length guidance: + +- Summarize scenarios and outcomes in 1-3 bullets. +- Mention test file/name when useful; skip unnecessary narrative. + +Include: + +- What scenarios were tested (happy path, failure path, regression) +- Why those checks prove the change works +- The key test file and test name where applicable + +Do not include: + +- Lists of shell commands used during development + +### Docs + +Explain docs impact based on user-facing behavior. + +Length guidance: + +- Usually 1 bullet is enough. +- Add a second bullet only when linking/following up on a docs PR needs context. + +- If docs are not needed, briefly explain why (for example: internal bug fix, no docs-facing behavior change). +- If docs are needed, link the associated docs PR. + +## PR Template (Recommended) + +```md +## Changes + +- +- + +## Testing + +- Validated and to confirm . +- Added/updated coverage in `` (``), which reproduces the original failure and verifies the fix. + +## Docs + +- +- > +``` + +## Bad vs Good + +### Bad + +```md +## Changes + +- Fixed prerenderer error details +- Added a test for the issue +- Added changesets + +## Testing + +- pnpm -C packages/astro build +- pnpm -C packages/integrations/cloudflare exec astro-scripts test test/prerenderer-errors.test.js +``` + +Why this is bad: + +- Mixes behavior changes with process chores (`Added a test`, `Added changesets`) +- `Testing` is command-only and does not explain validation intent + +### Good + +```md +## Changes + +- Surface Cloudflare prerenderer response body details in build errors for missing `getStaticPaths()` and static image collection failures, so workerd-originated failures are actionable. +- Ensure prerenderer teardown runs in a `finally` path during build generation, so cleanup still happens when prerendering throws. + +## Testing + +- Reproduced the Cloudflare prerender failure with a dynamic route missing `getStaticPaths()` and verified the thrown error now includes workerd response details. +- Added regression coverage in `packages/integrations/cloudflare/test/prerenderer-errors.test.js` (`surfaces prerenderer body details for missing getStaticPaths`) to lock in the message quality improvement. + +## Docs + +- No docs update needed; this changes internal error reporting quality and teardown reliability. +``` + +## Self-Check Before Posting + +- Every `Changes` bullet describes behavior/implementation/impact (not a task log) +- `Testing` explains scenarios and expected outcomes, with no shell command lists +- At least one concrete test reference is included when tests were added/changed +- No mention of routine changesets unless there is an unusual release-related reason +- `Docs` decision is explicit; link docs PR when docs updates are required diff --git a/.changeset/fair-buttons-float.md b/.changeset/fair-buttons-float.md new file mode 100644 index 000000000000..f23f307d54a8 --- /dev/null +++ b/.changeset/fair-buttons-float.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes `astro:actions` validation to check resolved routes, so projects using default static output with at least one `prerender = false` page or endpoint no longer fail during startup. diff --git a/.changeset/odd-bears-chew.md b/.changeset/odd-bears-chew.md new file mode 100644 index 000000000000..6aa6c434c7b4 --- /dev/null +++ b/.changeset/odd-bears-chew.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Ensure custom prerenderers are always torn down during build, even when `getStaticPaths()` throws. diff --git a/.changeset/polite-poets-push.md b/.changeset/polite-poets-push.md new file mode 100644 index 000000000000..b115cd83646c --- /dev/null +++ b/.changeset/polite-poets-push.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes an issue where the build incorrectly leaked server entrypoint into the client environment, causing adapters to emit warnings during the build. diff --git a/.changeset/slick-pans-smash.md b/.changeset/slick-pans-smash.md new file mode 100644 index 000000000000..f07d114e378d --- /dev/null +++ b/.changeset/slick-pans-smash.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes JSON schema generation for content collection schemas that have differences between their input and output shapes. diff --git a/.changeset/soft-lamps-warn.md b/.changeset/soft-lamps-warn.md new file mode 100644 index 000000000000..9c5459d23bb6 --- /dev/null +++ b/.changeset/soft-lamps-warn.md @@ -0,0 +1,5 @@ +--- +'create-astro': patch +--- + +Avoid spawning package manager commands with `shell: true` to prevent Node.js DEP0190 warnings during `create-astro` runs on newer Node versions. diff --git a/.changeset/ten-rats-wave.md b/.changeset/ten-rats-wave.md new file mode 100644 index 000000000000..884a6c608e81 --- /dev/null +++ b/.changeset/ten-rats-wave.md @@ -0,0 +1,5 @@ +--- +'@astrojs/cloudflare': patch +--- + +Include workerd response details in Cloudflare prerenderer errors to make `getStaticPaths()` failures easier to diagnose. diff --git a/.changeset/wet-cases-grow.md b/.changeset/wet-cases-grow.md new file mode 100644 index 000000000000..bd7d347d6649 --- /dev/null +++ b/.changeset/wet-cases-grow.md @@ -0,0 +1,6 @@ +--- +'@astrojs/language-server': patch +'astro-vscode': patch +--- + +Updates Volar services to 0.0.70. This updates notably mean that the transitive dependency yaml-language-server no longer depends on a vulnerable version of lodash, causing warnings to show when installing the language server. diff --git a/packages/astro/src/actions/integration.ts b/packages/astro/src/actions/integration.ts index 7b7812b0fa5a..0a5f155698f3 100644 --- a/packages/astro/src/actions/integration.ts +++ b/packages/astro/src/actions/integration.ts @@ -1,5 +1,6 @@ import { AstroError } from '../core/errors/errors.js'; import { ActionsWithoutServerOutputError } from '../core/errors/errors-data.js'; +import { hasNonPrerenderedProjectRoute } from '../core/routing/helpers.js'; import { viteID } from '../core/util.js'; import type { AstroSettings } from '../types/astro.js'; import type { AstroIntegration } from '../types/public/integrations.js'; @@ -29,22 +30,23 @@ export default function astroIntegrationActionsRouteHandler({ }); }, 'astro:config:done': async (params) => { - if (params.buildOutput === 'static') { - const error = new AstroError(ActionsWithoutServerOutputError); - error.stack = undefined; - throw error; - } - const stringifiedActionsImport = JSON.stringify( viteID(new URL(`./${filename}`, params.config.srcDir)), ); settings.injectedTypes.push({ filename: ACTIONS_TYPES_FILE, content: `declare module "astro:actions" { - export const actions: typeof import(${stringifiedActionsImport})["server"]; + export const actions: typeof import(${stringifiedActionsImport})["server"]; }`, }); }, + 'astro:routes:resolved': ({ routes }) => { + if (!hasNonPrerenderedProjectRoute(routes)) { + const error = new AstroError(ActionsWithoutServerOutputError); + error.stack = undefined; + throw error; + } + }, }, }; } diff --git a/packages/astro/src/content/types-generator.ts b/packages/astro/src/content/types-generator.ts index 4671fea75438..0c6ac353db83 100644 --- a/packages/astro/src/content/types-generator.ts +++ b/packages/astro/src/content/types-generator.ts @@ -648,6 +648,9 @@ async function generateJSONSchema( ctx.jsonSchema.format = 'date-time'; } }, + // Collection schemas are used for parsing collection input, so we need to tell Zod to use the + // input shape when generating a JSON schema. + io: 'input', }); const schemaStr = JSON.stringify(schema, null, 2); const schemaJsonPath = new URL( diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 7c300d76bd05..890ba1a8aced 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -85,140 +85,143 @@ export async function generatePages( const verb = ssr ? 'prerendering' : 'generating'; logger.info('SKIP_FORMAT', `\n${colors.bgGreen(colors.black(` ${verb} static routes `))}`); - - // Get all static paths with their routes from the prerenderer - const pathsWithRoutes = await prerenderer.getStaticPaths(); const routeToHeaders: RouteToHeaders = new Map(); + let staticImageList = getStaticImageList(); - // Check if i18n domains are configured (incompatible with prerendering) - const hasI18nDomains = - ssr && - options.settings.config.i18n?.domains && - Object.keys(options.settings.config.i18n.domains).length > 0; - - // Filter paths for conflicts (same path from multiple routes) - const { config } = options.settings; - const builtPaths = new Set(); - const filteredPaths = pathsWithRoutes.filter(({ pathname, route }) => { - // i18n domains won't work with prerendered routes - if (hasI18nDomains && route.prerender) { - throw new AstroError({ - ...AstroErrorData.NoPrerenderedRoutesWithDomains, - message: AstroErrorData.NoPrerenderedRoutesWithDomains.message(route.component), - }); - } + try { + // Get all static paths with their routes from the prerenderer + const pathsWithRoutes = await prerenderer.getStaticPaths(); + + // Check if i18n domains are configured (incompatible with prerendering) + const hasI18nDomains = + ssr && + options.settings.config.i18n?.domains && + Object.keys(options.settings.config.i18n.domains).length > 0; + + // Filter paths for conflicts (same path from multiple routes) + const { config } = options.settings; + const builtPaths = new Set(); + const filteredPaths = pathsWithRoutes.filter(({ pathname, route }) => { + // i18n domains won't work with prerendered routes + if (hasI18nDomains && route.prerender) { + throw new AstroError({ + ...AstroErrorData.NoPrerenderedRoutesWithDomains, + message: AstroErrorData.NoPrerenderedRoutesWithDomains.message(route.component), + }); + } - const normalized = removeTrailingForwardSlash(pathname); + const normalized = removeTrailingForwardSlash(pathname); - // Path hasn't been built yet, include it - if (!builtPaths.has(normalized)) { - builtPaths.add(normalized); - return true; - } + // Path hasn't been built yet, include it + if (!builtPaths.has(normalized)) { + builtPaths.add(normalized); + return true; + } - // Path was already built. Check if this route has higher priority. - const matchedRoute = matchRoute(decodeURI(pathname), options.routesList); - if (!matchedRoute) { - return false; - } + // Path was already built. Check if this route has higher priority. + const matchedRoute = matchRoute(decodeURI(pathname), options.routesList); + if (!matchedRoute) { + return false; + } - if (matchedRoute === route) { - // Current route is higher-priority. Include it for building. - return true; - } + if (matchedRoute === route) { + // Current route is higher-priority. Include it for building. + return true; + } - // Current route is lower-priority. Warn or error based on config. - if (config.prerenderConflictBehavior === 'error') { - throw new AstroError({ - ...AstroErrorData.PrerenderRouteConflict, - message: AstroErrorData.PrerenderRouteConflict.message( + // Current route is lower-priority. Warn or error based on config. + if (config.prerenderConflictBehavior === 'error') { + throw new AstroError({ + ...AstroErrorData.PrerenderRouteConflict, + message: AstroErrorData.PrerenderRouteConflict.message( + matchedRoute.route, + route.route, + normalized, + ), + hint: AstroErrorData.PrerenderRouteConflict.hint(matchedRoute.route, route.route), + }); + } else if (config.prerenderConflictBehavior === 'warn') { + const msg = AstroErrorData.PrerenderRouteConflict.message( matchedRoute.route, route.route, normalized, - ), - hint: AstroErrorData.PrerenderRouteConflict.hint(matchedRoute.route, route.route), - }); - } else if (config.prerenderConflictBehavior === 'warn') { - const msg = AstroErrorData.PrerenderRouteConflict.message( - matchedRoute.route, - route.route, - normalized, - ); - logger.warn('build', msg); - } + ); + logger.warn('build', msg); + } - return false; - }); + return false; + }); - // Generate each path - if (config.build.concurrency > 1) { - const limit = PLimit(config.build.concurrency); - // Process in batches to avoid V8's Promise.all element limit, which is around ~123k items - // - // NOTE: ideally we could consider an iterator to avoid the batching limitation - const BATCH_SIZE = 100_000; - for (let i = 0; i < filteredPaths.length; i += BATCH_SIZE) { - const batch = filteredPaths.slice(i, i + BATCH_SIZE); - const promises: Promise[] = []; - for (const { pathname, route } of batch) { - promises.push( - limit(() => - generatePathWithPrerenderer( - prerenderer, - pathname, - route, - options, - routeToHeaders, - logger, + // Generate each path + if (config.build.concurrency > 1) { + const limit = PLimit(config.build.concurrency); + // Process in batches to avoid V8's Promise.all element limit, which is around ~123k items + // + // NOTE: ideally we could consider an iterator to avoid the batching limitation + const BATCH_SIZE = 100_000; + for (let i = 0; i < filteredPaths.length; i += BATCH_SIZE) { + const batch = filteredPaths.slice(i, i + BATCH_SIZE); + const promises: Promise[] = []; + for (const { pathname, route } of batch) { + promises.push( + limit(() => + generatePathWithPrerenderer( + prerenderer, + pathname, + route, + options, + routeToHeaders, + logger, + ), ), - ), + ); + } + await Promise.all(promises); + } + } else { + for (const { pathname, route } of filteredPaths) { + await generatePathWithPrerenderer( + prerenderer, + pathname, + route, + options, + routeToHeaders, + logger, ); } - await Promise.all(promises); - } - } else { - for (const { pathname, route } of filteredPaths) { - await generatePathWithPrerenderer( - prerenderer, - pathname, - route, - options, - routeToHeaders, - logger, - ); } - } - // After generation, propagate distURL from the deserialized routes (used during generation) - // back to the original routes in allPages. The prerenderer operates on deserialized route - // objects (reconstructed from the serialized manifest), so distURL mutations during generation - // don't affect the original route objects that are later passed to the astro:build:done hook. - for (const { route: generatedRoute } of filteredPaths) { - if (generatedRoute.distURL && generatedRoute.distURL.length > 0) { - for (const pageData of Object.values(options.allPages)) { - if ( - pageData.route.route === generatedRoute.route && - pageData.route.component === generatedRoute.component - ) { - pageData.route.distURL = generatedRoute.distURL; - break; + // After generation, propagate distURL from the deserialized routes (used during generation) + // back to the original routes in allPages. The prerenderer operates on deserialized route + // objects (reconstructed from the serialized manifest), so distURL mutations during generation + // don't affect the original route objects that are later passed to the astro:build:done hook. + for (const { route: generatedRoute } of filteredPaths) { + if (generatedRoute.distURL && generatedRoute.distURL.length > 0) { + for (const pageData of Object.values(options.allPages)) { + if ( + pageData.route.route === generatedRoute.route && + pageData.route.component === generatedRoute.component + ) { + pageData.route.distURL = generatedRoute.distURL; + break; + } } } } - } - const staticImageList = getStaticImageList(); - - // Must happen before teardown since collectStaticImages fetches from the prerender server - if (prerenderer.collectStaticImages) { - const adapterImages = await prerenderer.collectStaticImages(); - for (const [path, entry] of adapterImages) { - staticImageList.set(path, entry); + // Must happen before teardown since collectStaticImages fetches from the prerender server + staticImageList = getStaticImageList(); + if (prerenderer.collectStaticImages) { + const adapterImages = await prerenderer.collectStaticImages(); + for (const [path, entry] of adapterImages) { + staticImageList.set(path, entry); + } } + } finally { + // Always teardown to avoid leaking adapter resources when generation fails. + await prerenderer.teardown?.(); } - // Teardown the prerenderer - await prerenderer.teardown?.(); logger.info( null, colors.green(`✓ Completed in ${getTimeStat(generatePagesTimer, performance.now())}.\n`), diff --git a/packages/astro/src/core/build/plugins/plugin-internals.ts b/packages/astro/src/core/build/plugins/plugin-internals.ts index b17c7fa35ba7..f4bc265f7152 100644 --- a/packages/astro/src/core/build/plugins/plugin-internals.ts +++ b/packages/astro/src/core/build/plugins/plugin-internals.ts @@ -1,15 +1,25 @@ -import type { EnvironmentOptions, Plugin as VitePlugin } from 'vite'; +import type { EnvironmentOptions, Plugin as VitePlugin, Rollup } from 'vite'; import type { BuildInternals } from '../internal.js'; import type { StaticBuildOptions } from '../types.js'; import { normalizeEntryId } from './plugin-component-entry.js'; import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../../constants.js'; +function getRollupInputAsSet(rollupInput: Rollup.InputOption | undefined): Set { + if (Array.isArray(rollupInput)) { + return new Set(rollupInput); + } else if (typeof rollupInput === 'string') { + return new Set([rollupInput]); + } else if (rollupInput && typeof rollupInput === 'object') { + return new Set(Object.values(rollupInput) as string[]); + } else { + return new Set(); + } +} + export function pluginInternals( options: StaticBuildOptions, internals: BuildInternals, ): VitePlugin { - let input: Set; - return { name: '@astro/plugin-build-internals', @@ -42,21 +52,15 @@ export function pluginInternals( } }, - configResolved(config) { - // Get input from rollupOptions - const rollupInput = config.build?.rollupOptions?.input; - if (Array.isArray(rollupInput)) { - input = new Set(rollupInput); - } else if (typeof rollupInput === 'string') { - input = new Set([rollupInput]); - } else if (rollupInput && typeof rollupInput === 'object') { - input = new Set(Object.values(rollupInput) as string[]); - } else { - input = new Set(); - } - }, - async generateBundle(_options, bundle) { + // Read the rollup input directly from the current environment's config rather than + // relying on a closure variable from configResolved. With Vite's per-environment config + // resolution, a shared closure variable would be overwritten by the last environment's + // configResolved call, causing inputs from one environment (e.g. SSR) to leak into + // another (e.g. client). This caused server-only modules like @astrojs/node/server.js + // to be resolved in the client environment, triggering spurious "externalized for + // browser compatibility" warnings. + const input = getRollupInputAsSet(this.environment?.config.build.rollupOptions.input); const promises = []; const mapping = new Map>(); const allInput = new Set([...input, ...internals.clientInput]); diff --git a/packages/astro/src/core/routing/helpers.ts b/packages/astro/src/core/routing/helpers.ts index 48a2bfa82c39..64e9975ac691 100644 --- a/packages/astro/src/core/routing/helpers.ts +++ b/packages/astro/src/core/routing/helpers.ts @@ -1,4 +1,5 @@ import type { RouteData } from '../../types/public/internal.js'; +import type { IntegrationResolvedRoute } from '../../types/public/integrations.js'; import type { RouteInfo } from '../app/types.js'; import type { RoutesList } from '../../types/astro.js'; import { isRoute404, isRoute500 } from './internal/route-errors.js'; @@ -62,3 +63,27 @@ export function getCustom404Route(manifestData: RoutesList): RouteData | undefin export function getCustom500Route(manifestData: RoutesList): RouteData | undefined { return manifestData.routes.find((r) => isRoute500(r.route)); } + +export function hasNonPrerenderedProjectRoute( + routes: Array>, + options?: { includeEndpoints?: boolean }, +): boolean; +export function hasNonPrerenderedProjectRoute( + routes: Array>, + options?: { includeEndpoints?: boolean }, +): boolean; +export function hasNonPrerenderedProjectRoute( + routes: Array< + | Pick + | Pick + >, + options?: { includeEndpoints?: boolean }, +): boolean { + const includeEndpoints = options?.includeEndpoints ?? true; + const routeTypes: ReadonlyArray = includeEndpoints ? ['page', 'endpoint'] : ['page']; + + return routes.some((route) => { + const isPrerendered = 'isPrerendered' in route ? route.isPrerendered : route.prerender; + return routeTypes.includes(route.type) && route.origin === 'project' && !isPrerendered; + }); +} diff --git a/packages/astro/src/vite-plugin-renderers/index.ts b/packages/astro/src/vite-plugin-renderers/index.ts index e131379c777e..8277c52fc961 100644 --- a/packages/astro/src/vite-plugin-renderers/index.ts +++ b/packages/astro/src/vite-plugin-renderers/index.ts @@ -1,5 +1,6 @@ import type { ConfigEnv, Plugin as VitePlugin } from 'vite'; import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../core/constants.js'; +import { hasNonPrerenderedProjectRoute } from '../core/routing/helpers.js'; import type { AstroSettings, RoutesList } from '../types/astro.js'; export const ASTRO_RENDERERS_MODULE_ID = 'virtual:astro:renderers'; @@ -11,17 +12,6 @@ interface PluginOptions { command: ConfigEnv['command']; } -/** - * Checks whether any non-prerendered route needs component rendering (i.e., is a page). - * Internal routes like `_server-islands` are excluded because they only need renderers - * when server islands are actually used, and those are detected separately during the build. - */ -function ssrBuildNeedsRenderers(routesList: RoutesList): boolean { - return routesList.routes.some( - (route) => route.type === 'page' && !route.prerender && route.origin !== 'internal', - ); -} - export default function vitePluginRenderers(options: PluginOptions): VitePlugin { const renderers = options.settings.renderers; @@ -47,7 +37,9 @@ export default function vitePluginRenderers(options: PluginOptions): VitePlugin options.command === 'build' && this.environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr && renderers.length > 0 && - !ssrBuildNeedsRenderers(options.routesList) + !hasNonPrerenderedProjectRoute(options.routesList.routes, { + includeEndpoints: false, + }) ) { return { code: `export const renderers = [];` }; } diff --git a/packages/astro/test/actions.test.js b/packages/astro/test/actions.test.js index 129ecf6cbadb..3262123f8b6f 100644 --- a/packages/astro/test/actions.test.js +++ b/packages/astro/test/actions.test.js @@ -636,6 +636,41 @@ describe('Astro Actions', () => { }); }); +describe('Astro Actions in static mode with prerender = false routes', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + let devServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/actions-static-prerender-false/', + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer?.stop(); + }); + + it('starts in dev and exposes action RPC routes', async () => { + assert.ok(devServer, 'Expected dev server to start'); + + const res = await fixture.fetch('/_actions/ping', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: '{}', + }); + + assert.equal(res.ok, true); + assert.equal(res.headers.get('Content-Type'), 'application/json+devalue'); + + const data = devalue.parse(await res.text()); + assert.equal(data.ok, true); + }); +}); + it('Base path should be used', async () => { const fixture = await loadFixture({ root: './fixtures/actions/', diff --git a/packages/astro/test/content-intellisense.test.js b/packages/astro/test/content-intellisense.test.js index 56a6d232f62f..d40545d4d13a 100644 --- a/packages/astro/test/content-intellisense.test.js +++ b/packages/astro/test/content-intellisense.test.js @@ -107,4 +107,16 @@ describe('Content Intellisense', () => { true, ); }); + + it('uses the Zod input shape to generate the JSON schema', async () => { + const schema = JSON.parse( + await fixture.readFile('../.astro/collections/io-differences.schema.json'), + ); + assert.deepEqual(schema.properties.optionalWithDefault, { + type: 'string', + default: 'default value', + }); + // The optional field with a default should bot be required. + assert.ok(!schema.required.includes('optionalWithDefault')); + }); }); diff --git a/packages/astro/test/data-collections-schema.test.js b/packages/astro/test/data-collections-schema.test.js index b4627d5cb62d..56f4ee4c8011 100644 --- a/packages/astro/test/data-collections-schema.test.js +++ b/packages/astro/test/data-collections-schema.test.js @@ -41,14 +41,12 @@ describe('Content Collections - data collections', () => { }, }, required: ['greeting', 'preamble'], - additionalProperties: false, }, $schema: { type: 'string', }, }, required: ['homepage'], - additionalProperties: false, }), JSON.stringify(JSON.parse(rawJson)), ); @@ -80,14 +78,12 @@ describe('Content Collections - data collections', () => { }, }, required: ['greeting', 'preamble', 'image'], - additionalProperties: false, }, $schema: { type: 'string', }, }, required: ['homepage'], - additionalProperties: false, }), JSON.stringify(JSON.parse(rawJson)), ); diff --git a/packages/astro/test/fixtures/actions-static-prerender-false/astro.config.mjs b/packages/astro/test/fixtures/actions-static-prerender-false/astro.config.mjs new file mode 100644 index 000000000000..86dbfb924824 --- /dev/null +++ b/packages/astro/test/fixtures/actions-static-prerender-false/astro.config.mjs @@ -0,0 +1,3 @@ +import { defineConfig } from 'astro/config'; + +export default defineConfig({}); diff --git a/packages/astro/test/fixtures/actions-static-prerender-false/src/actions/index.ts b/packages/astro/test/fixtures/actions-static-prerender-false/src/actions/index.ts new file mode 100644 index 000000000000..520defd199c9 --- /dev/null +++ b/packages/astro/test/fixtures/actions-static-prerender-false/src/actions/index.ts @@ -0,0 +1,9 @@ +import { defineAction } from 'astro:actions'; + +export const server = { + ping: defineAction({ + handler: async () => { + return { ok: true }; + }, + }), +}; diff --git a/packages/astro/test/fixtures/actions-static-prerender-false/src/pages/index.astro b/packages/astro/test/fixtures/actions-static-prerender-false/src/pages/index.astro new file mode 100644 index 000000000000..04940d6a696b --- /dev/null +++ b/packages/astro/test/fixtures/actions-static-prerender-false/src/pages/index.astro @@ -0,0 +1,5 @@ +--- +export const prerender = false; +--- + +

Live page

diff --git a/packages/astro/test/fixtures/content-intellisense/src/content.config.ts b/packages/astro/test/fixtures/content-intellisense/src/content.config.ts index 52b223e09cd3..bf7a9385442e 100644 --- a/packages/astro/test/fixtures/content-intellisense/src/content.config.ts +++ b/packages/astro/test/fixtures/content-intellisense/src/content.config.ts @@ -33,11 +33,23 @@ const dataDates = defineCollection({ }) }) +// Zod features like `.default()` create a difference in the input and output shape of a schema. +// This schema helps us check we use the input shape in generated JSON schemas. +const schemaWithIODifferences = z.object({ + optionalWithDefault: z.string().optional().default('default value'), + requiredProperty: z.string(), +}); +const ioDifferences = defineCollection({ + loader: () => [{ id: '1', requiredProperty: 'defined' }], + schema: schemaWithIODifferences +}); + export const collections = { "blog-cc": blogCC, "blog-cl": blogCL, "data-cl": dataYML, "data-cl-json": dataJSON, "data-schema-misuse": dataWithSchemaMisuse, - "data-dates": dataDates + "data-dates": dataDates, + "io-differences": ioDifferences, }; diff --git a/packages/create-astro/src/shell.ts b/packages/create-astro/src/shell.ts index 9d49d197f5fd..ac2b5004a77b 100644 --- a/packages/create-astro/src/shell.ts +++ b/packages/create-astro/src/shell.ts @@ -5,6 +5,8 @@ import { spawn } from 'node:child_process'; import type { Readable } from 'node:stream'; import { text as textFromStream } from 'node:stream/consumers'; +const WINDOWS_CMD_SHIMS = new Set(['npm', 'npx', 'pnpm', 'pnpx', 'yarn', 'yarnpkg', 'bun', 'bunx']); + interface ExecaOptions { cwd?: string | URL; stdio?: StdioOptions; @@ -18,6 +20,12 @@ interface Output { const text = (stream: NodeJS.ReadableStream | Readable | null) => stream ? textFromStream(stream).then((t) => t.trimEnd()) : ''; +function resolveCommand(command: string) { + if (process.platform !== 'win32') return command; + if (command.includes('/') || command.includes('\\') || command.includes('.')) return command; + return WINDOWS_CMD_SHIMS.has(command.toLowerCase()) ? `${command}.cmd` : command; +} + export async function shell( command: string, flags: string[], @@ -27,15 +35,16 @@ export async function shell( let stdout = ''; let stderr = ''; try { - child = spawn(command, flags, { + child = spawn(resolveCommand(command), flags, { cwd: opts.cwd, - shell: true, stdio: opts.stdio, timeout: opts.timeout, }); - const done = new Promise((resolve) => child.on('close', resolve)); - [stdout, stderr] = await Promise.all([text(child.stdout), text(child.stderr)]); - await done; + const done = new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', () => resolve()); + }); + [stdout, stderr] = await Promise.all([text(child.stdout), text(child.stderr), done]); } catch { throw { stdout, stderr, exitCode: 1 }; } diff --git a/packages/integrations/cloudflare/src/prerenderer.ts b/packages/integrations/cloudflare/src/prerenderer.ts index 8a3554ea695e..8b452ab7b22a 100644 --- a/packages/integrations/cloudflare/src/prerenderer.ts +++ b/packages/integrations/cloudflare/src/prerenderer.ts @@ -89,9 +89,10 @@ export function createCloudflarePrerenderer({ }); if (!response.ok) { + const body = await response.text(); + const details = body ? `\n${body}` : ''; throw new Error( - `Failed to get static paths from the Cloudflare prerender server (${response.status}: ${response.statusText}). ` + - 'This is likely a bug in @astrojs/cloudflare. Please file an issue at https://github.com/withastro/astro/issues', + `Failed to get static paths from the Cloudflare prerender server (${response.status}: ${response.statusText}).${details}`, ); } @@ -128,8 +129,10 @@ export function createCloudflarePrerenderer({ }); if (!response.ok) { + const body = await response.text(); + const details = body ? `\n${body}` : ''; throw new Error( - `Failed to get static images from the Cloudflare prerender server (${response.status}: ${response.statusText}).`, + `Failed to get static images from the Cloudflare prerender server (${response.status}: ${response.statusText}).${details}`, ); } diff --git a/packages/integrations/cloudflare/test/fixtures/prerenderer-errors/astro.config.mjs b/packages/integrations/cloudflare/test/fixtures/prerenderer-errors/astro.config.mjs new file mode 100644 index 000000000000..e709309674b9 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/prerenderer-errors/astro.config.mjs @@ -0,0 +1,5 @@ +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + output: 'static', +}); diff --git a/packages/integrations/cloudflare/test/fixtures/prerenderer-errors/package.json b/packages/integrations/cloudflare/test/fixtures/prerenderer-errors/package.json new file mode 100644 index 000000000000..b0aebfec95a8 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/prerenderer-errors/package.json @@ -0,0 +1,12 @@ +{ + "name": "@test/astro-cloudflare-prerenderer-errors", + "version": "0.0.0", + "private": true, + "scripts": { + "build": "astro build" + }, + "dependencies": { + "@astrojs/cloudflare": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/cloudflare/test/fixtures/prerenderer-errors/src/pages/[...slug].astro b/packages/integrations/cloudflare/test/fixtures/prerenderer-errors/src/pages/[...slug].astro new file mode 100644 index 000000000000..e0e95529a2ae --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/prerenderer-errors/src/pages/[...slug].astro @@ -0,0 +1 @@ +

Dynamic test route

diff --git a/packages/integrations/cloudflare/test/prerenderer-errors.test.js b/packages/integrations/cloudflare/test/prerenderer-errors.test.js new file mode 100644 index 000000000000..329b08eaaa88 --- /dev/null +++ b/packages/integrations/cloudflare/test/prerenderer-errors.test.js @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { loadFixture } from '../../../astro/test/test-utils.js'; +import cloudflare from '../dist/index.js'; + +describe('Cloudflare prerenderer errors', () => { + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/prerenderer-errors/', import.meta.url).toString(), + adapter: cloudflare(), + }); + }); + + after(async () => { + await fixture.clean(); + }); + + it('includes workerd error details when getStaticPaths fails', async () => { + await assert.rejects( + async () => { + await fixture.build({}, { teardownCompiler: true }); + }, + (error) => { + assert.ok(error instanceof Error); + assert.match( + error.message, + /Failed to get static paths from the Cloudflare prerender server/, + ); + assert.match(error.message, /getStaticPaths\(\).*required for dynamic routes/); + return true; + }, + ); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a05170ba88e..3eb644db812e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5045,6 +5045,15 @@ importers: specifier: ^4.2.1 version: 4.2.1 + packages/integrations/cloudflare/test/fixtures/prerenderer-errors: + dependencies: + '@astrojs/cloudflare': + specifier: workspace:* + version: link:../../.. + astro: + specifier: workspace:* + version: link:../../../../../astro + packages/integrations/cloudflare/test/fixtures/routing-priority: dependencies: '@astrojs/cloudflare': @@ -5920,7 +5929,7 @@ importers: version: 5.2.1 fastify: specifier: ^5.7.4 - version: 5.7.4 + version: 5.8.1 node-mocks-http: specifier: ^1.17.2 version: 1.17.2(@types/node@22.19.11) @@ -10733,8 +10742,8 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} alpinejs@3.15.8: resolution: {integrity: sha512-zxIfCRTBGvF1CCLIOMQOxAyBuqibxSEwS6Jm1a3HGA9rgrJVcjEWlwLcQTVGAWGS8YhAsTRLVrtQ5a5QT9bSSQ==} @@ -12097,8 +12106,8 @@ packages: fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} - fastify@5.7.4: - resolution: {integrity: sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==} + fastify@5.8.1: + resolution: {integrity: sha512-y0kicFvvn7CYWoPOVLOcvn4YyKQz03DIY7UxmyOy21/J8eXm09R+tmb+tVDBW5h+pja30cHI5dqUcSlvY86V2A==} fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -12148,8 +12157,8 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} - find-my-way@9.4.0: - resolution: {integrity: sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==} + find-my-way@9.5.0: + resolution: {integrity: sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==} engines: {node: '>=20'} find-up-simple@1.0.1: @@ -17619,7 +17628,7 @@ snapshots: '@fastify/ajv-compiler@4.0.5': dependencies: - ajv: 8.17.1 + ajv: 8.18.0 ajv-formats: 3.0.1 fast-uri: 3.1.0 @@ -17641,7 +17650,7 @@ snapshots: dependencies: '@fastify/error': 4.2.0 fastify-plugin: 5.1.0 - find-my-way: 9.4.0 + find-my-way: 9.5.0 path-to-regexp: 8.3.0 reusify: 1.1.0 @@ -18124,9 +18133,9 @@ snapshots: '@netlify/edge-bundler@14.9.8': dependencies: '@import-maps/resolve': 2.0.0 - ajv: 8.17.1 - ajv-errors: 3.0.0(ajv@8.17.1) - better-ajv-errors: 1.2.0(ajv@8.17.1) + ajv: 8.18.0 + ajv-errors: 3.0.0(ajv@8.18.0) + better-ajv-errors: 1.2.0(ajv@8.18.0) common-path-prefix: 3.0.0 env-paths: 3.0.0 esbuild: 0.27.2 @@ -18783,7 +18792,7 @@ snapshots: '@secretlint/profiler': 10.2.2 '@secretlint/resolver': 10.2.2 '@secretlint/types': 10.2.2 - ajv: 8.17.1 + ajv: 8.18.0 debug: 4.4.3(supports-color@8.1.1) rc-config-loader: 4.1.3 transitivePeerDependencies: @@ -19964,17 +19973,17 @@ snapshots: agent-base@7.1.4: {} - ajv-draft-04@1.0.0(ajv@8.17.1): + ajv-draft-04@1.0.0(ajv@8.18.0): optionalDependencies: - ajv: 8.17.1 + ajv: 8.18.0 - ajv-errors@3.0.0(ajv@8.17.1): + ajv-errors@3.0.0(ajv@8.18.0): dependencies: - ajv: 8.17.1 + ajv: 8.18.0 ajv-formats@3.0.1: dependencies: - ajv: 8.17.1 + ajv: 8.18.0 ajv@6.12.6: dependencies: @@ -19983,7 +19992,7 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.17.1: + ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 fast-uri: 3.1.0 @@ -20200,11 +20209,11 @@ snapshots: bcp-47-match@2.0.3: {} - better-ajv-errors@1.2.0(ajv@8.17.1): + better-ajv-errors@1.2.0(ajv@8.18.0): dependencies: '@babel/code-frame': 7.29.0 '@humanwhocodes/momoa': 2.0.4 - ajv: 8.17.1 + ajv: 8.18.0 chalk: 4.1.2 jsonpointer: 5.0.1 leven: 3.1.0 @@ -21358,7 +21367,7 @@ snapshots: fast-json-stringify@6.3.0: dependencies: '@fastify/merge-json-schemas': 0.2.1 - ajv: 8.17.1 + ajv: 8.18.0 ajv-formats: 3.0.1 fast-uri: 3.1.0 json-schema-ref-resolver: 3.0.0 @@ -21383,7 +21392,7 @@ snapshots: fastify-plugin@5.1.0: {} - fastify@5.7.4: + fastify@5.8.1: dependencies: '@fastify/ajv-compiler': 4.0.5 '@fastify/error': 4.2.0 @@ -21392,7 +21401,7 @@ snapshots: abstract-logging: 2.0.1 avvio: 9.2.0 fast-json-stringify: 6.3.0 - find-my-way: 9.4.0 + find-my-way: 9.5.0 light-my-request: 6.6.0 pino: 10.3.1 process-warning: 5.0.0 @@ -21451,7 +21460,7 @@ snapshots: transitivePeerDependencies: - supports-color - find-my-way@9.4.0: + find-my-way@9.5.0: dependencies: fast-deep-equal: 3.1.3 fast-querystring: 1.1.2 @@ -25031,7 +25040,7 @@ snapshots: table@6.9.0: dependencies: - ajv: 8.17.1 + ajv: 8.18.0 lodash.truncate: 4.4.2 slice-ansi: 4.0.0 string-width: 4.2.3 @@ -26071,8 +26080,8 @@ snapshots: yaml-language-server@1.20.0: dependencies: '@vscode/l10n': 0.0.18 - ajv: 8.17.1 - ajv-draft-04: 1.0.0(ajv@8.17.1) + ajv: 8.18.0 + ajv-draft-04: 1.0.0(ajv@8.18.0) prettier: 3.8.1 request-light: 0.5.8 vscode-json-languageservice: 4.1.8 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 16fb7962a1ba..f3f2ee58e15c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -38,6 +38,8 @@ minimumReleaseAgeExclude: - fast-xml-parser@5.3.8 # Renovate security update: svelte@5.53.5 - svelte@5.53.5 + # Renovate security update: fastify@5.8.1 + - fastify@5.8.1 peerDependencyRules: allowAny: - 'astro'