mirror of
https://github.com/withastro/astro.git
synced 2025-12-05 18:56:38 -06:00
Compare commits
4 Commits
95a1969a05
...
d7889f768a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7889f768a | ||
|
|
8c7cf89fe7 | ||
|
|
29f8924d0a | ||
|
|
141c4a2641 |
12
.changeset/adapter-interface-breaking.md
Normal file
12
.changeset/adapter-interface-breaking.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
'astro': minor
|
||||
---
|
||||
|
||||
Adds new optional properties to `setAdapter()` for adapter entrypoint handling in the Adapter API
|
||||
|
||||
**Changes:**
|
||||
- New optional properties:
|
||||
- `devEntrypoint?: string | URL` - specifies custom dev server entrypoint
|
||||
- `entryType?: 'self' | 'legacy-dynamic'` - determines if the adapter provides its own entrypoint (`'self'`) or if Astro constructs one (`'legacy-dynamic'`, default)
|
||||
|
||||
**Migration:** Adapter authors can optionally add these properties to support custom dev entrypoints. If not specified, adapters will use the legacy behavior.
|
||||
63
.changeset/cloudflare-entrypoint-breaking.md
Normal file
63
.changeset/cloudflare-entrypoint-breaking.md
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
'@astrojs/cloudflare': major
|
||||
---
|
||||
|
||||
Changes the API for creating a custom `entrypoint`, replacing the `createExports()` function with a direct export pattern.
|
||||
|
||||
#### What should I do?
|
||||
|
||||
If you're using a custom `entryPoint` in your Cloudflare adapter config, update your existing worker file that uses `createExports()` to reflect the new, simplified pattern:
|
||||
|
||||
|
||||
__my-entry.ts__
|
||||
|
||||
```ts
|
||||
import type { SSRManifest } from 'astro';
|
||||
import { App } from 'astro/app';
|
||||
import { handle } from '@astrojs/cloudflare/handler'
|
||||
import { DurableObject } from 'cloudflare:workers';
|
||||
|
||||
class MyDurableObject extends DurableObject<Env> {
|
||||
constructor(ctx: DurableObjectState, env: Env) {
|
||||
super(ctx, env)
|
||||
}
|
||||
}
|
||||
|
||||
export function createExports(manifest: SSRManifest) {
|
||||
const app = new App(manifest);
|
||||
return {
|
||||
default: {
|
||||
async fetch(request, env, ctx) {
|
||||
await env.MY_QUEUE.send("log");
|
||||
return handle(manifest, app, request, env, ctx);
|
||||
},
|
||||
async queue(batch, _env) {
|
||||
let messages = JSON.stringify(batch.messages);
|
||||
console.log(`consumed from our queue: ${messages}`);
|
||||
}
|
||||
} satisfies ExportedHandler<Env>,
|
||||
MyDurableObject: MyDurableObject,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To create the same custom `entrypoint` using the updated API, export the following function instead:
|
||||
|
||||
__my-entry.ts__
|
||||
|
||||
```ts
|
||||
import { handle } from '@astrojs/cloudflare/utils/handler';
|
||||
|
||||
export default {
|
||||
async fetch(request, env, ctx) {
|
||||
await env.MY_QUEUE.send("log");
|
||||
return handle(manifest, app, request, env, ctx);
|
||||
},
|
||||
async queue(batch, _env) {
|
||||
let messages = JSON.stringify(batch.messages);
|
||||
console.log(`consumed from our queue: ${messages}`);
|
||||
}
|
||||
} satisfies ExportedHandler<Env>,
|
||||
```
|
||||
|
||||
The manifest is now created internally by the adapter.
|
||||
5
.changeset/deep-states-talk.md
Normal file
5
.changeset/deep-states-talk.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'astro': major
|
||||
---
|
||||
|
||||
Updates how schema types are inferred for content loaders with schemas (Loader API) - ([v6 upgrade guidance](https://deploy-preview-12322--astro-docs-2.netlify.app/en/guides/upgrade-to/v6/TODO:))
|
||||
5
.changeset/encoding-static-builds.md
Normal file
5
.changeset/encoding-static-builds.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'astro': major
|
||||
---
|
||||
|
||||
Removes support for routes with percent-encoded percent signs (e.g. `%25`) - ([v6 upgrade guidance](https://deploy-preview-12322--astro-docs-2.netlify.app/en/guides/upgrade-to/v6/#removed-percent-encoding-in-routes))
|
||||
7
.changeset/flat-lions-care.md
Normal file
7
.changeset/flat-lions-care.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@astrojs/cloudflare': minor
|
||||
---
|
||||
|
||||
Adds support for `astro preview` command
|
||||
|
||||
Developers can now use `astro preview` to test their Cloudflare Workers application locally before deploying. The preview runs using Cloudflare's workerd runtime, giving you a staging environment that matches production exactly—including support for KV namespaces, environment variables, and other Cloudflare-specific features.
|
||||
5
.changeset/fresh-rocks-sing.md
Normal file
5
.changeset/fresh-rocks-sing.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'astro': major
|
||||
---
|
||||
|
||||
Removes the option to define dynamic schemas in content loaders as functions and adds a new equivalent `createSchema()` property (Loader API) - ([v6 upgrade guidance](https://deploy-preview-12322--astro-docs-2.netlify.app/en/guides/upgrade-to/v6/TODO:))
|
||||
33
.changeset/open-monkeys-boil.md
Normal file
33
.changeset/open-monkeys-boil.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
'@astrojs/cloudflare': major
|
||||
---
|
||||
|
||||
Development server now runs in workerd
|
||||
|
||||
`astro dev` now runs your Cloudflare application using Cloudflare's workerd runtime instead of Node.js. This means your development environment is now a near-exact replica of your production environment—the same JavaScript engine, the same APIs, the same behavior. You'll catch issues during development that would have only appeared in production, and features like Durable Objects, Workers Analytics Engine, and R2 bindings work exactly as they do on Cloudflare's platform.
|
||||
|
||||
To accommodate this major change to your development environment, this update includes breaking changes to `Astro.locals.runtime`, removing some of its properties.
|
||||
|
||||
#### What should I do?
|
||||
|
||||
Update occurrences of `Astro.locals.runtime` as shown below:
|
||||
|
||||
- `Astro.locals.runtime` no longer contains the `env` object. Instead, import it directly:
|
||||
```js
|
||||
import { env } from 'cloudflare:workers';
|
||||
```
|
||||
|
||||
- `Astro.locals.runtime` no longer contains the `cf` object. Instead, access it directly from the request:
|
||||
```js
|
||||
Astro.request.cf
|
||||
```
|
||||
|
||||
- `Astro.locals.runtime` no longer contains the `caches` object. Instead, use the global `caches` object directly:
|
||||
```js
|
||||
caches.default.put(request, response)
|
||||
```
|
||||
|
||||
- `Astro.locals.runtime` object is replaced with `Astro.locals.cfContext` which contains the Cloudflare `ExecutionContext`:
|
||||
```js
|
||||
const cfContext = Astro.locals.cfContext;
|
||||
```
|
||||
5
.changeset/route-data-breaking.md
Normal file
5
.changeset/route-data-breaking.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'astro': major
|
||||
---
|
||||
|
||||
Removes `RouteData.generate` from the Integration API - ([v6 upgrade guidance](https://deploy-preview-12322--astro-docs-2.netlify.app/en/guides/upgrade-to/v6/#removed-routedatagenerate-adapter-api))
|
||||
5
.changeset/ssr-manifest-breaking.md
Normal file
5
.changeset/ssr-manifest-breaking.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'astro': major
|
||||
---
|
||||
|
||||
Changes the shape of `SSRManifest` properties and adds several new required properties in the Adapter API - ([v6 upgrade guidance](https://deploy-preview-12322--astro-docs-2.netlify.app/en/guides/upgrade-to/v6/#changed-ssrmanifest-interface-structure-adapter-api))
|
||||
5
.changeset/vite-environments-breaking.md
Normal file
5
.changeset/vite-environments-breaking.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'astro': major
|
||||
---
|
||||
|
||||
Changes integration hooks and HMR access patterns in the Integration API - ([v6 upgrade guidance](https://deploy-preview-12322--astro-docs-2.netlify.app/en/guides/upgrade-to/v6/#changed-integration-hooks-and-hmr-access-patterns-integration-api))
|
||||
5
.changeset/wet-lines-wear.md
Normal file
5
.changeset/wet-lines-wear.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'astro': major
|
||||
---
|
||||
|
||||
Removes the unused `astro:ssr-manifest` virtual module - ([v6 upgrade guidance](https://deploy-preview-12322--astro-docs-2.netlify.app/en/guides/upgrade-to/v6/#removed-astrossr-manifest-virtual-module-integration-api))
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as fs from 'node:fs';
|
||||
import type { SSRManifest } from 'astro';
|
||||
import { App } from 'astro/app';
|
||||
import { AppPipeline, BaseApp } from 'astro/app';
|
||||
|
||||
class MyApp extends App {
|
||||
class MyApp extends BaseApp {
|
||||
#manifest: SSRManifest | undefined;
|
||||
constructor(manifest: SSRManifest, streaming = false) {
|
||||
super(manifest, streaming);
|
||||
@@ -19,6 +19,13 @@ class MyApp extends App {
|
||||
|
||||
return super.render(request);
|
||||
}
|
||||
|
||||
createPipeline(streaming: boolean) {
|
||||
return AppPipeline.create({
|
||||
manifest: this.manifest,
|
||||
streaming,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createExports(manifest: SSRManifest) {
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"assist": {
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
"organizeImports": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
9
knip.js
9
knip.js
@@ -32,8 +32,15 @@ export default {
|
||||
'test/types/**/*',
|
||||
'e2e/**/*.test.js',
|
||||
'test/units/teardown.js',
|
||||
// Can't detect this file when using inside a vite plugin
|
||||
'src/vite-plugin-app/createAstroServerApp.ts',
|
||||
],
|
||||
ignore: [
|
||||
'**/e2e/**/{fixtures,_temp-fixtures}/**',
|
||||
'performance/**/*',
|
||||
// This export is resolved dynamically in packages/astro/src/vite-plugin-app/index.ts
|
||||
'src/vite-plugin-app/createExports.ts',
|
||||
],
|
||||
ignore: ['**/e2e/**/{fixtures,_temp-fixtures}/**', 'performance/**/*'],
|
||||
// Those deps are used in tests but only referenced as strings
|
||||
ignoreDependencies: [
|
||||
'rehype-autolink-headings',
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"test:e2e:hosts": "turbo run test:hosted",
|
||||
"benchmark": "astro-benchmark",
|
||||
"lint": "biome lint && knip && eslint . --report-unused-disable-directives-severity=warn --concurrency=auto",
|
||||
"lint:ci": "biome ci --formatter-enabled=false --reporter=github && eslint . --concurrency=auto --report-unused-disable-directives-severity=warn && knip",
|
||||
"lint:ci": "biome ci --formatter-enabled=false --enforce-assist=false --reporter=github && eslint . --concurrency=auto --report-unused-disable-directives-severity=warn && knip",
|
||||
"lint:fix": "biome lint --write --unsafe",
|
||||
"publint": "pnpm -r --filter=astro --filter=create-astro --filter=\"@astrojs/*\" --no-bail exec publint",
|
||||
"version": "changeset version && node ./scripts/deps/update-example-versions.js && pnpm install --no-frozen-lockfile && pnpm run format",
|
||||
|
||||
62
packages/astro/dev-only.d.ts
vendored
62
packages/astro/dev-only.d.ts
vendored
@@ -22,3 +22,65 @@ declare module 'virtual:astro:actions/options' {
|
||||
declare module 'virtual:astro:actions/runtime' {
|
||||
export * from './src/actions/runtime/client.js';
|
||||
}
|
||||
|
||||
declare module 'virtual:astro:actions/entrypoint' {
|
||||
import type { SSRActions } from './src/index.js';
|
||||
export const server: SSRActions;
|
||||
}
|
||||
|
||||
declare module 'virtual:astro:manifest' {
|
||||
import type { SSRManifest } from './src/index.js';
|
||||
export const manifest: SSRManifest;
|
||||
}
|
||||
|
||||
declare module 'virtual:astro:routes' {
|
||||
import type { RoutesList } from './src/types/astro.js';
|
||||
export const routes: RoutesList[];
|
||||
}
|
||||
|
||||
declare module 'virtual:astro:renderers' {
|
||||
import type { AstroRenderer } from './src/index.js';
|
||||
export const renderers: AstroRenderer[];
|
||||
}
|
||||
|
||||
declare module 'virtual:astro:middleware' {
|
||||
import type { AstroMiddlewareInstance } from './src/index.js';
|
||||
const middleware: AstroMiddlewareInstance;
|
||||
export default middleware;
|
||||
}
|
||||
|
||||
declare module 'virtual:astro:session-driver' {
|
||||
import type { Driver } from 'unstorage';
|
||||
export const driver: Driver;
|
||||
}
|
||||
|
||||
declare module 'virtual:astro:pages' {
|
||||
export const pageMap: Map<string, () => Promise<any>>;
|
||||
}
|
||||
|
||||
declare module 'virtual:astro:server-islands' {
|
||||
export const serverIslandMap: Map<string, () => Promise<any>>;
|
||||
}
|
||||
|
||||
declare module 'virtual:astro:adapter-entrypoint' {
|
||||
export const createExports: ((manifest: any, args: any) => any) | undefined;
|
||||
export const start: ((manifest: any, args: any) => void) | undefined;
|
||||
export default any;
|
||||
}
|
||||
|
||||
declare module 'virtual:astro:adapter-config' {
|
||||
export const args: any;
|
||||
export const exports: string[] | undefined;
|
||||
export const adapterFeatures: any;
|
||||
export const serverEntrypoint: string;
|
||||
}
|
||||
|
||||
declare module 'virtual:astro:dev-css' {
|
||||
import type { ImportedDevStyles } from './src/types/astro.js';
|
||||
export const css: Set<ImportedDevStyles>;
|
||||
}
|
||||
|
||||
declare module 'virtual:astro:dev-css-all' {
|
||||
import type { ImportedDevStyles } from './src/types/astro.js';
|
||||
export const devCSSMap: Map<string, () => Promise<{ css: Set<ImportedDevStyles> }>>;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"solid-js": "^1.9.10",
|
||||
"svelte": "^5.43.6",
|
||||
"svelte": "^5.43.14",
|
||||
"vue": "^3.5.24"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,9 @@
|
||||
"./container": "./dist/container/index.js",
|
||||
"./app": "./dist/core/app/index.js",
|
||||
"./app/node": "./dist/core/app/node.js",
|
||||
"./app/entrypoint": "./dist/core/app/entrypoint.js",
|
||||
"./entrypoints/prerender": "./dist/entrypoints/prerender.js",
|
||||
"./entrypoints/legacy": "./dist/entrypoints/legacy.js",
|
||||
"./client/*": "./dist/runtime/client/*",
|
||||
"./components": "./components/index.ts",
|
||||
"./components/*": "./components/*",
|
||||
@@ -59,6 +62,7 @@
|
||||
"./assets": "./dist/assets/index.js",
|
||||
"./assets/runtime": "./dist/assets/runtime.js",
|
||||
"./assets/utils": "./dist/assets/utils/index.js",
|
||||
"./assets/utils/node": "./dist/assets/utils/node.js",
|
||||
"./assets/utils/inferRemoteSize.js": "./dist/assets/utils/remoteProbe.js",
|
||||
"./assets/endpoint/*": "./dist/assets/endpoint/*.js",
|
||||
"./assets/services/sharp": "./dist/assets/services/sharp.js",
|
||||
@@ -168,8 +172,7 @@
|
||||
"yargs-parser": "^21.1.1",
|
||||
"yocto-spinner": "^0.2.3",
|
||||
"zod": "^3.25.76",
|
||||
"zod-to-json-schema": "^3.24.6",
|
||||
"zod-to-ts": "^1.2.0"
|
||||
"zod-to-json-schema": "^3.24.6"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"sharp": "^0.34.0"
|
||||
|
||||
@@ -7,8 +7,9 @@ export const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;
|
||||
export const RUNTIME_VIRTUAL_MODULE_ID = 'virtual:astro:actions/runtime';
|
||||
export const RESOLVED_RUNTIME_VIRTUAL_MODULE_ID = '\0' + RUNTIME_VIRTUAL_MODULE_ID;
|
||||
|
||||
export const ENTRYPOINT_VIRTUAL_MODULE_ID = 'virtual:astro:actions/entrypoint';
|
||||
export const RESOLVED_ENTRYPOINT_VIRTUAL_MODULE_ID = '\0' + ENTRYPOINT_VIRTUAL_MODULE_ID;
|
||||
export const ACTIONS_ENTRYPOINT_VIRTUAL_MODULE_ID = 'virtual:astro:actions/entrypoint';
|
||||
export const ACTIONS_RESOLVED_ENTRYPOINT_VIRTUAL_MODULE_ID =
|
||||
'\0' + ACTIONS_ENTRYPOINT_VIRTUAL_MODULE_ID;
|
||||
|
||||
/** Used to pass data from the config to the main virtual module */
|
||||
export const OPTIONS_VIRTUAL_MODULE_ID = 'virtual:astro:actions/options';
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { SSRActions } from '../core/app/types.js';
|
||||
import { ActionsCantBeLoaded } from '../core/errors/errors-data.js';
|
||||
import { AstroError } from '../core/errors/index.js';
|
||||
import type { ModuleLoader } from '../core/module-loader/index.js';
|
||||
import { ENTRYPOINT_VIRTUAL_MODULE_ID } from './consts.js';
|
||||
|
||||
/**
|
||||
* It accepts a module loader and the astro settings, and it attempts to load the middlewares defined in the configuration.
|
||||
*
|
||||
* If not middlewares were not set, the function returns an empty array.
|
||||
*/
|
||||
export async function loadActions(moduleLoader: ModuleLoader) {
|
||||
try {
|
||||
return (await moduleLoader.import(ENTRYPOINT_VIRTUAL_MODULE_ID)) as SSRActions;
|
||||
} catch (error: any) {
|
||||
throw new AstroError(ActionsCantBeLoaded, { cause: error });
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
import type fsMod from 'node:fs';
|
||||
import type { Plugin as VitePlugin } from 'vite';
|
||||
import { addRollupInput } from '../core/build/add-rollup-input.js';
|
||||
import type { BuildInternals } from '../core/build/internal.js';
|
||||
import type { StaticBuildOptions } from '../core/build/types.js';
|
||||
import { shouldAppendForwardSlash } from '../core/build/util.js';
|
||||
import { getServerOutputDirectory } from '../prerender/utils.js';
|
||||
import type { AstroSettings } from '../types/astro.js';
|
||||
import {
|
||||
ENTRYPOINT_VIRTUAL_MODULE_ID,
|
||||
ACTIONS_ENTRYPOINT_VIRTUAL_MODULE_ID,
|
||||
ACTIONS_RESOLVED_ENTRYPOINT_VIRTUAL_MODULE_ID,
|
||||
OPTIONS_VIRTUAL_MODULE_ID,
|
||||
RESOLVED_ENTRYPOINT_VIRTUAL_MODULE_ID,
|
||||
RESOLVED_NOOP_ENTRYPOINT_VIRTUAL_MODULE_ID,
|
||||
RESOLVED_OPTIONS_VIRTUAL_MODULE_ID,
|
||||
RESOLVED_RUNTIME_VIRTUAL_MODULE_ID,
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
VIRTUAL_MODULE_ID,
|
||||
} from './consts.js';
|
||||
import { isActionsFilePresent } from './utils.js';
|
||||
import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../core/constants.js';
|
||||
|
||||
/**
|
||||
* This plugin is used to retrieve the final entry point of the bundled actions.ts file
|
||||
@@ -31,15 +31,15 @@ export function vitePluginActionsBuild(
|
||||
return {
|
||||
name: '@astro/plugin-actions-build',
|
||||
|
||||
options(options) {
|
||||
return addRollupInput(options, [ENTRYPOINT_VIRTUAL_MODULE_ID]);
|
||||
applyToEnvironment(environment) {
|
||||
return environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr;
|
||||
},
|
||||
|
||||
writeBundle(_, bundle) {
|
||||
for (const [chunkName, chunk] of Object.entries(bundle)) {
|
||||
if (
|
||||
chunk.type !== 'asset' &&
|
||||
chunk.facadeModuleId === RESOLVED_ENTRYPOINT_VIRTUAL_MODULE_ID
|
||||
chunk.facadeModuleId === ACTIONS_RESOLVED_ENTRYPOINT_VIRTUAL_MODULE_ID
|
||||
) {
|
||||
const outputDirectory = getServerOutputDirectory(opts.settings);
|
||||
internals.astroActionsEntryPoint = new URL(chunkName, outputDirectory);
|
||||
@@ -74,7 +74,7 @@ export function vitePluginActions({
|
||||
return RESOLVED_OPTIONS_VIRTUAL_MODULE_ID;
|
||||
}
|
||||
|
||||
if (id === ENTRYPOINT_VIRTUAL_MODULE_ID) {
|
||||
if (id === ACTIONS_ENTRYPOINT_VIRTUAL_MODULE_ID) {
|
||||
const resolvedModule = await this.resolve(
|
||||
`${decodeURI(new URL('actions', settings.config.srcDir).pathname)}`,
|
||||
);
|
||||
@@ -84,7 +84,7 @@ export function vitePluginActions({
|
||||
}
|
||||
|
||||
resolvedActionsId = resolvedModule.id;
|
||||
return RESOLVED_ENTRYPOINT_VIRTUAL_MODULE_ID;
|
||||
return ACTIONS_RESOLVED_ENTRYPOINT_VIRTUAL_MODULE_ID;
|
||||
}
|
||||
},
|
||||
async configureServer(server) {
|
||||
@@ -108,7 +108,7 @@ export function vitePluginActions({
|
||||
return { code: 'export const server = {}' };
|
||||
}
|
||||
|
||||
if (id === RESOLVED_ENTRYPOINT_VIRTUAL_MODULE_ID) {
|
||||
if (id === ACTIONS_RESOLVED_ENTRYPOINT_VIRTUAL_MODULE_ID) {
|
||||
return { code: `export { server } from ${JSON.stringify(resolvedActionsId)};` };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import fs, { readFileSync } from 'node:fs';
|
||||
import { basename } from 'node:path/posix';
|
||||
import colors from 'piccolore';
|
||||
import type { BuildApp } from '../../core/build/app.js';
|
||||
import { getOutDirWithinCwd } from '../../core/build/common.js';
|
||||
import type { BuildPipeline } from '../../core/build/pipeline.js';
|
||||
import { getTimeStat } from '../../core/build/util.js';
|
||||
import { AstroError } from '../../core/errors/errors.js';
|
||||
import { AstroErrorData } from '../../core/errors/index.js';
|
||||
@@ -50,12 +50,14 @@ type ImageData = {
|
||||
};
|
||||
|
||||
export async function prepareAssetsGenerationEnv(
|
||||
pipeline: BuildPipeline,
|
||||
app: BuildApp,
|
||||
totalCount: number,
|
||||
): Promise<AssetEnv> {
|
||||
const { config, logger, settings } = pipeline;
|
||||
const settings = app.getSettings();
|
||||
const logger = app.logger;
|
||||
const manifest = app.getManifest();
|
||||
let useCache = true;
|
||||
const assetsCacheDir = new URL('assets/', config.cacheDir);
|
||||
const assetsCacheDir = new URL('assets/', app.manifest.cacheDir);
|
||||
const count = { total: totalCount, current: 1 };
|
||||
|
||||
// Ensure that the cache directory exists
|
||||
@@ -72,11 +74,11 @@ export async function prepareAssetsGenerationEnv(
|
||||
const isServerOutput = settings.buildOutput === 'server';
|
||||
let serverRoot: URL, clientRoot: URL;
|
||||
if (isServerOutput) {
|
||||
serverRoot = config.build.server;
|
||||
clientRoot = config.build.client;
|
||||
serverRoot = manifest.buildServerDir;
|
||||
clientRoot = manifest.buildClientDir;
|
||||
} else {
|
||||
serverRoot = getOutDirWithinCwd(config.outDir);
|
||||
clientRoot = config.outDir;
|
||||
serverRoot = getOutDirWithinCwd(manifest.outDir);
|
||||
clientRoot = manifest.outDir;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -87,8 +89,8 @@ export async function prepareAssetsGenerationEnv(
|
||||
assetsCacheDir,
|
||||
serverRoot,
|
||||
clientRoot,
|
||||
imageConfig: config.image,
|
||||
assetsFolder: config.build.assets,
|
||||
imageConfig: settings.config.image,
|
||||
assetsFolder: manifest.assetsDir,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -48,10 +48,10 @@ function getImageEndpointData(
|
||||
segments,
|
||||
params: [],
|
||||
component: resolveInjectedRoute(endpointEntrypoint, settings.config.root, cwd).component,
|
||||
generate: () => '',
|
||||
pathname: settings.config.image.endpoint.route,
|
||||
prerender: false,
|
||||
fallbackRoutes: [],
|
||||
origin: 'internal',
|
||||
distURL: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ViteDevServer } from 'vite';
|
||||
import type { RunnableDevEnvironment } from 'vite';
|
||||
import type { RemoteFontProviderModResolver } from '../definitions.js';
|
||||
|
||||
export function createBuildRemoteFontProviderModResolver(): RemoteFontProviderModResolver {
|
||||
@@ -10,13 +10,13 @@ export function createBuildRemoteFontProviderModResolver(): RemoteFontProviderMo
|
||||
}
|
||||
|
||||
export function createDevServerRemoteFontProviderModResolver({
|
||||
server,
|
||||
environment,
|
||||
}: {
|
||||
server: ViteDevServer;
|
||||
environment: RunnableDevEnvironment;
|
||||
}): RemoteFontProviderModResolver {
|
||||
return {
|
||||
resolve(id) {
|
||||
return server.ssrLoadModule(id);
|
||||
return environment.runner.import(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import colors from 'piccolore';
|
||||
import type { Plugin } from 'vite';
|
||||
import type { Plugin, RunnableDevEnvironment } from 'vite';
|
||||
import { getAlgorithm, shouldTrackCspHashes } from '../../core/csp/common.js';
|
||||
import { generateCspDigest } from '../../core/encryption.js';
|
||||
import { collectErrorMetadata } from '../../core/errors/dev/utils.js';
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
import { createBuildUrlResolver, createDevUrlResolver } from './infra/url-resolver.js';
|
||||
import { orchestrate } from './orchestrate.js';
|
||||
import type { ConsumableMap, FontFileDataMap, InternalConsumableMap } from './types.js';
|
||||
import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../../core/constants.js';
|
||||
|
||||
interface Options {
|
||||
settings: AstroSettings;
|
||||
@@ -233,7 +234,11 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
|
||||
await initialize({
|
||||
// In dev, we cache fonts data in .astro so it can be easily inspected and cleared
|
||||
cacheDir: new URL(CACHE_DIR, settings.dotAstroDir),
|
||||
modResolver: createDevServerRemoteFontProviderModResolver({ server }),
|
||||
modResolver: createDevServerRemoteFontProviderModResolver({
|
||||
environment: server.environments[
|
||||
ASTRO_VITE_ENVIRONMENT_NAMES.astro
|
||||
] as RunnableDevEnvironment,
|
||||
}),
|
||||
cssRenderer: createMinifiableCssRenderer({ minify: false }),
|
||||
urlResolver: createDevUrlResolver({
|
||||
base: baseUrl,
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
export { isESMImportedImage, isRemoteImage, resolveSrc } from './imageKind.js';
|
||||
export { imageMetadata } from './metadata.js';
|
||||
export { emitImageMetadata } from './node/emitAsset.js';
|
||||
export { getOrigQueryParams } from './queryParams.js';
|
||||
export {
|
||||
isRemoteAllowed,
|
||||
@@ -19,4 +18,3 @@ export {
|
||||
type RemotePattern,
|
||||
} from './remotePattern.js';
|
||||
export { inferRemoteSize } from './remoteProbe.js';
|
||||
export { hashTransform, propsToFilename } from './transformToPath.js';
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import path, { basename, dirname, extname } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { deterministicString } from 'deterministic-object-hash';
|
||||
import type * as vite from 'vite';
|
||||
import { generateContentHash } from '../../../core/encryption.js';
|
||||
import { prependForwardSlash, slash } from '../../../core/path.js';
|
||||
import type { ImageMetadata } from '../../types.js';
|
||||
import { imageMetadata } from '../metadata.js';
|
||||
import { generateContentHash } from '../../core/encryption.js';
|
||||
import { prependForwardSlash, removeQueryString, slash } from '../../core/path.js';
|
||||
import { shorthash } from '../../runtime/server/shorthash.js';
|
||||
import type { ImageMetadata, ImageTransform } from '../types.js';
|
||||
import { isESMImportedImage } from './imageKind.js';
|
||||
import { imageMetadata } from './metadata.js';
|
||||
|
||||
type FileEmitter = vite.Rollup.EmitFile;
|
||||
type ImageMetadataWithContents = ImageMetadata & { contents?: Buffer };
|
||||
@@ -93,7 +96,7 @@ export async function emitImageMetadata(
|
||||
Object.defineProperty(emittedImage, 'fsPath', {
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
value: id,
|
||||
value: fileURLToNormalizedPath(url),
|
||||
});
|
||||
|
||||
// Build
|
||||
@@ -139,3 +142,70 @@ function fileURLToNormalizedPath(filePath: URL): string {
|
||||
// Uses `slash` instead of Vite's `normalizePath` to avoid CJS bundling issues.
|
||||
return slash(fileURLToPath(filePath) + filePath.search).replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
// Taken from https://github.com/rollup/rollup/blob/a8647dac0fe46c86183be8596ef7de25bc5b4e4b/src/utils/sanitizeFileName.ts
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const INVALID_CHAR_REGEX = /[\u0000-\u001F"#$%&*+,:;<=>?[\]^`{|}\u007F]/g;
|
||||
|
||||
/**
|
||||
* Converts a file path and transformation properties of the transformation image service, into a formatted filename.
|
||||
*
|
||||
* The formatted filename follows this structure:
|
||||
*
|
||||
* `<prefixDirname>/<baseFilename>_<hash><outputExtension>`
|
||||
*
|
||||
* - `prefixDirname`: If the image is an ESM imported image, this is the directory name of the original file path; otherwise, it will be an empty string.
|
||||
* - `baseFilename`: The base name of the file or a hashed short name if the file is a `data:` URI.
|
||||
* - `hash`: A unique hash string generated to distinguish the transformed file.
|
||||
* - `outputExtension`: The desired output file extension derived from the `transform.format` or the original file extension.
|
||||
*
|
||||
* ## Example
|
||||
* - Input: `filePath = '/images/photo.jpg'`, `transform = { format: 'png', src: '/images/photo.jpg' }`, `hash = 'abcd1234'`.
|
||||
* - Output: `/images/photo_abcd1234.png`
|
||||
*
|
||||
* @param {string} filePath - The original file path or data URI of the source image.
|
||||
* @param {ImageTransform} transform - An object representing the transformation properties, including format and source.
|
||||
* @param {string} hash - A unique hash used to differentiate the transformed file.
|
||||
* @return {string} The generated filename based on the provided input, transformations, and hash.
|
||||
*/
|
||||
|
||||
export function propsToFilename(filePath: string, transform: ImageTransform, hash: string): string {
|
||||
let filename = decodeURIComponent(removeQueryString(filePath));
|
||||
const ext = extname(filename);
|
||||
if (filePath.startsWith('data:')) {
|
||||
filename = shorthash(filePath);
|
||||
} else {
|
||||
filename = basename(filename, ext).replace(INVALID_CHAR_REGEX, '_');
|
||||
}
|
||||
const prefixDirname = isESMImportedImage(transform.src) ? dirname(filePath) : '';
|
||||
|
||||
let outputExt = transform.format ? `.${transform.format}` : ext;
|
||||
return `${prefixDirname}/${filename}_${hash}${outputExt}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the provided `transform` object into a hash string based on selected properties
|
||||
* and the specified `imageService`.
|
||||
*
|
||||
* @param {ImageTransform} transform - The transform object containing various image transformation properties.
|
||||
* @param {string} imageService - The name of the image service related to the transform.
|
||||
* @param {string[]} propertiesToHash - An array of property names from the `transform` object that should be used to generate the hash.
|
||||
* @return {string} A hashed string created from the specified properties of the `transform` object and the image service.
|
||||
*/
|
||||
export function hashTransform(
|
||||
transform: ImageTransform,
|
||||
imageService: string,
|
||||
propertiesToHash: string[],
|
||||
): string {
|
||||
// Extract the fields we want to hash
|
||||
const hashFields = propertiesToHash.reduce(
|
||||
(acc, prop) => {
|
||||
// It's possible for `transform[prop]` here to be undefined, or null, but that's fine because it's still consistent
|
||||
// between different transforms. (ex: every transform without a height will explicitly have a `height: undefined` property)
|
||||
acc[prop] = transform[prop];
|
||||
return acc;
|
||||
},
|
||||
{ imageService } as Record<string, unknown>,
|
||||
);
|
||||
return shorthash(deterministicString(hashFields));
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { basename, dirname, extname } from 'node:path';
|
||||
import { deterministicString } from 'deterministic-object-hash';
|
||||
import { removeQueryString } from '../../core/path.js';
|
||||
import { shorthash } from '../../runtime/server/shorthash.js';
|
||||
import type { ImageTransform } from '../types.js';
|
||||
import { isESMImportedImage } from './imageKind.js';
|
||||
|
||||
// Taken from https://github.com/rollup/rollup/blob/a8647dac0fe46c86183be8596ef7de25bc5b4e4b/src/utils/sanitizeFileName.ts
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const INVALID_CHAR_REGEX = /[\u0000-\u001F"#$%&*+,:;<=>?[\]^`{|}\u007F]/g;
|
||||
|
||||
/**
|
||||
* Converts a file path and transformation properties of the transformation image service, into a formatted filename.
|
||||
*
|
||||
* The formatted filename follows this structure:
|
||||
*
|
||||
* `<prefixDirname>/<baseFilename>_<hash><outputExtension>`
|
||||
*
|
||||
* - `prefixDirname`: If the image is an ESM imported image, this is the directory name of the original file path; otherwise, it will be an empty string.
|
||||
* - `baseFilename`: The base name of the file or a hashed short name if the file is a `data:` URI.
|
||||
* - `hash`: A unique hash string generated to distinguish the transformed file.
|
||||
* - `outputExtension`: The desired output file extension derived from the `transform.format` or the original file extension.
|
||||
*
|
||||
* ## Example
|
||||
* - Input: `filePath = '/images/photo.jpg'`, `transform = { format: 'png', src: '/images/photo.jpg' }`, `hash = 'abcd1234'`.
|
||||
* - Output: `/images/photo_abcd1234.png`
|
||||
*
|
||||
* @param {string} filePath - The original file path or data URI of the source image.
|
||||
* @param {ImageTransform} transform - An object representing the transformation properties, including format and source.
|
||||
* @param {string} hash - A unique hash used to differentiate the transformed file.
|
||||
* @return {string} The generated filename based on the provided input, transformations, and hash.
|
||||
*/
|
||||
|
||||
export function propsToFilename(filePath: string, transform: ImageTransform, hash: string): string {
|
||||
let filename = decodeURIComponent(removeQueryString(filePath));
|
||||
const ext = extname(filename);
|
||||
if (filePath.startsWith('data:')) {
|
||||
filename = shorthash(filePath);
|
||||
} else {
|
||||
filename = basename(filename, ext).replace(INVALID_CHAR_REGEX, '_');
|
||||
}
|
||||
const prefixDirname = isESMImportedImage(transform.src) ? dirname(filePath) : '';
|
||||
|
||||
let outputExt = transform.format ? `.${transform.format}` : ext;
|
||||
return `${prefixDirname}/${filename}_${hash}${outputExt}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the provided `transform` object into a hash string based on selected properties
|
||||
* and the specified `imageService`.
|
||||
*
|
||||
* @param {ImageTransform} transform - The transform object containing various image transformation properties.
|
||||
* @param {string} imageService - The name of the image service related to the transform.
|
||||
* @param {string[]} propertiesToHash - An array of property names from the `transform` object that should be used to generate the hash.
|
||||
* @return {string} A hashed string created from the specified properties of the `transform` object and the image service.
|
||||
*/
|
||||
export function hashTransform(
|
||||
transform: ImageTransform,
|
||||
imageService: string,
|
||||
propertiesToHash: string[],
|
||||
): string {
|
||||
// Extract the fields we want to hash
|
||||
const hashFields = propertiesToHash.reduce(
|
||||
(acc, prop) => {
|
||||
// It's possible for `transform[prop]` here to be undefined, or null, but that's fine because it's still consistent
|
||||
// between different transforms. (ex: every transform without a height will explicitly have a `height: undefined` property)
|
||||
acc[prop] = transform[prop];
|
||||
return acc;
|
||||
},
|
||||
{ imageService } as Record<string, unknown>,
|
||||
);
|
||||
return shorthash(deterministicString(hashFields));
|
||||
}
|
||||
@@ -17,11 +17,10 @@ import { VALID_INPUT_FORMATS, VIRTUAL_MODULE_ID, VIRTUAL_SERVICE_ID } from './co
|
||||
import { fontsPlugin } from './fonts/vite-plugin-fonts.js';
|
||||
import type { ImageTransform } from './types.js';
|
||||
import { getAssetsPrefix } from './utils/getAssetsPrefix.js';
|
||||
import { isESMImportedImage } from './utils/imageKind.js';
|
||||
import { emitImageMetadata } from './utils/node/emitAsset.js';
|
||||
import { isESMImportedImage } from './utils/index.js';
|
||||
import { emitImageMetadata, hashTransform, propsToFilename } from './utils/node.js';
|
||||
import { getProxyCode } from './utils/proxy.js';
|
||||
import { makeSvgComponent } from './utils/svg.js';
|
||||
import { hashTransform, propsToFilename } from './utils/transformToPath.js';
|
||||
import { createPlaceholderURL, stringifyPlaceholderURL } from './utils/url.js';
|
||||
|
||||
const resolvedVirtualModuleId = '\0' + VIRTUAL_MODULE_ID;
|
||||
|
||||
@@ -70,7 +70,11 @@ export async function preferences(
|
||||
const inlineConfig = flagsToAstroInlineConfig(flags);
|
||||
const logger = createLoggerFromFlags(flags);
|
||||
const { astroConfig } = await resolveConfig(inlineConfig ?? {}, 'dev');
|
||||
const settings = await createSettings(astroConfig, fileURLToPath(astroConfig.root));
|
||||
const settings = await createSettings(
|
||||
astroConfig,
|
||||
inlineConfig.logLevel,
|
||||
fileURLToPath(astroConfig.root),
|
||||
);
|
||||
const opts: SubcommandOptions = {
|
||||
location: flags.global ? 'global' : undefined,
|
||||
json: !!flags.json,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// IMPORTANT: this file is the entrypoint for "astro/config". Keep it as light as possible!
|
||||
|
||||
import type { SharpImageServiceConfig } from '../assets/services/sharp.js';
|
||||
|
||||
import type { ImageServiceConfig } from '../types/public/index.js';
|
||||
|
||||
export { defineAstroFontProvider, fontProviders } from '../assets/fonts/providers/index.js';
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
Locales,
|
||||
SessionDriverName,
|
||||
} from '../types/public/config.js';
|
||||
import { createDevelopmentManifest } from '../vite-plugin-astro-server/plugin.js';
|
||||
|
||||
/**
|
||||
* See the full Astro Configuration API Documentation
|
||||
@@ -47,13 +46,18 @@ export function getViteConfig(
|
||||
]);
|
||||
const logger = createNodeLogger(inlineAstroConfig);
|
||||
const { astroConfig: config } = await resolveConfig(inlineAstroConfig, cmd);
|
||||
let settings = await createSettings(config, userViteConfig.root);
|
||||
let settings = await createSettings(config, inlineAstroConfig.logLevel, userViteConfig.root);
|
||||
settings = await runHookConfigSetup({ settings, command: cmd, logger });
|
||||
const routesList = await createRoutesList({ settings }, logger);
|
||||
const manifest = createDevelopmentManifest(settings);
|
||||
const routesList = await createRoutesList(
|
||||
{
|
||||
settings,
|
||||
},
|
||||
logger,
|
||||
{ dev: true, skipBuildOutputAssignment: false },
|
||||
);
|
||||
const viteConfig = await createVite(
|
||||
{},
|
||||
{ settings, command: cmd, logger, mode, sync: false, manifest, routesList },
|
||||
{ routesList, settings, command: cmd, logger, mode, sync: false },
|
||||
);
|
||||
await runHookConfigDone({ settings, logger });
|
||||
return mergeConfig(viteConfig, userViteConfig);
|
||||
|
||||
@@ -136,17 +136,20 @@ function createManifest(
|
||||
};
|
||||
}
|
||||
|
||||
const root = new URL(import.meta.url);
|
||||
return {
|
||||
hrefRoot: import.meta.url,
|
||||
srcDir: manifest?.srcDir ?? ASTRO_CONFIG_DEFAULTS.srcDir,
|
||||
buildClientDir: manifest?.buildClientDir ?? ASTRO_CONFIG_DEFAULTS.build.client,
|
||||
buildServerDir: manifest?.buildServerDir ?? ASTRO_CONFIG_DEFAULTS.build.server,
|
||||
publicDir: manifest?.publicDir ?? ASTRO_CONFIG_DEFAULTS.publicDir,
|
||||
outDir: manifest?.outDir ?? ASTRO_CONFIG_DEFAULTS.outDir,
|
||||
cacheDir: manifest?.cacheDir ?? ASTRO_CONFIG_DEFAULTS.cacheDir,
|
||||
rootDir: root,
|
||||
srcDir: manifest?.srcDir ?? new URL(ASTRO_CONFIG_DEFAULTS.srcDir, root),
|
||||
buildClientDir: manifest?.buildClientDir ?? new URL(ASTRO_CONFIG_DEFAULTS.build.client, root),
|
||||
buildServerDir: manifest?.buildServerDir ?? new URL(ASTRO_CONFIG_DEFAULTS.build.server, root),
|
||||
publicDir: manifest?.publicDir ?? new URL(ASTRO_CONFIG_DEFAULTS.publicDir, root),
|
||||
outDir: manifest?.outDir ?? new URL(ASTRO_CONFIG_DEFAULTS.outDir, root),
|
||||
cacheDir: manifest?.cacheDir ?? new URL(ASTRO_CONFIG_DEFAULTS.cacheDir, root),
|
||||
trailingSlash: manifest?.trailingSlash ?? ASTRO_CONFIG_DEFAULTS.trailingSlash,
|
||||
buildFormat: manifest?.buildFormat ?? ASTRO_CONFIG_DEFAULTS.build.format,
|
||||
compressHTML: manifest?.compressHTML ?? ASTRO_CONFIG_DEFAULTS.compressHTML,
|
||||
assetsDir: manifest?.assetsDir ?? ASTRO_CONFIG_DEFAULTS.build.assets,
|
||||
serverLike: manifest?.serverLike ?? true,
|
||||
assets: manifest?.assets ?? new Set(),
|
||||
assetsPrefix: manifest?.assetsPrefix ?? undefined,
|
||||
entryModules: manifest?.entryModules ?? {},
|
||||
@@ -164,6 +167,12 @@ function createManifest(
|
||||
middleware: manifest?.middleware ?? middlewareInstance,
|
||||
key: createKey(),
|
||||
csp: manifest?.csp,
|
||||
devToolbar: {
|
||||
enabled: false,
|
||||
latestAstroVersion: undefined,
|
||||
debugInfoOutput: '',
|
||||
},
|
||||
logLevel: 'silent',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -251,6 +260,8 @@ type AstroContainerManifest = Pick<
|
||||
| 'cacheDir'
|
||||
| 'csp'
|
||||
| 'allowedDomains'
|
||||
| 'serverLike'
|
||||
| 'assetsDir'
|
||||
>;
|
||||
|
||||
type AstroContainerConstructor = {
|
||||
@@ -284,7 +295,6 @@ export class experimental_AstroContainer {
|
||||
}),
|
||||
manifest: createManifest(manifest, renderers),
|
||||
streaming,
|
||||
serverLike: true,
|
||||
renderers: renderers ?? manifest?.renderers ?? [],
|
||||
resolve: async (specifier: string) => {
|
||||
if (this.#withManifest) {
|
||||
@@ -577,9 +587,6 @@ export class experimental_AstroContainer {
|
||||
return {
|
||||
route: url.pathname,
|
||||
component: '',
|
||||
generate(_data: any): string {
|
||||
return '';
|
||||
},
|
||||
params: Object.keys(params),
|
||||
pattern: getPattern(
|
||||
segments,
|
||||
@@ -592,6 +599,7 @@ export class experimental_AstroContainer {
|
||||
fallbackRoutes: [],
|
||||
isIndex: false,
|
||||
origin: 'internal',
|
||||
distURL: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -19,26 +19,18 @@ export class ContainerPipeline extends Pipeline {
|
||||
SinglePageBuiltModule
|
||||
>();
|
||||
|
||||
getName(): string {
|
||||
return 'ContainerPipeline';
|
||||
}
|
||||
|
||||
static create({
|
||||
logger,
|
||||
manifest,
|
||||
renderers,
|
||||
resolve,
|
||||
serverLike,
|
||||
streaming,
|
||||
}: Pick<
|
||||
ContainerPipeline,
|
||||
'logger' | 'manifest' | 'renderers' | 'resolve' | 'serverLike' | 'streaming'
|
||||
>) {
|
||||
return new ContainerPipeline(
|
||||
logger,
|
||||
manifest,
|
||||
'development',
|
||||
renderers,
|
||||
resolve,
|
||||
serverLike,
|
||||
streaming,
|
||||
);
|
||||
}: Pick<ContainerPipeline, 'logger' | 'manifest' | 'renderers' | 'resolve' | 'streaming'>) {
|
||||
return new ContainerPipeline(logger, manifest, 'development', renderers, resolve, streaming);
|
||||
}
|
||||
|
||||
componentMetadata(_routeData: RouteData): Promise<SSRResult['componentMetadata']> | void {}
|
||||
@@ -84,7 +76,6 @@ export class ContainerPipeline extends Pipeline {
|
||||
page() {
|
||||
return Promise.resolve(componentInstance);
|
||||
},
|
||||
renderers: this.manifest.renderers,
|
||||
onRequest: this.resolvedMiddleware,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -71,29 +71,6 @@ export type BaseSchema = ZodType;
|
||||
|
||||
export type SchemaContext = { image: ImageFunction };
|
||||
|
||||
type ContentLayerConfig<S extends BaseSchema, TData extends { id: string } = { id: string }> = {
|
||||
type?: 'content_layer';
|
||||
schema?: S | ((context: SchemaContext) => S);
|
||||
loader:
|
||||
| Loader
|
||||
| (() =>
|
||||
| Array<TData>
|
||||
| Promise<Array<TData>>
|
||||
| Record<string, Omit<TData, 'id'> & { id?: string }>
|
||||
| Promise<Record<string, Omit<TData, 'id'> & { id?: string }>>);
|
||||
};
|
||||
|
||||
type DataCollectionConfig<S extends BaseSchema> = {
|
||||
type: 'data';
|
||||
schema?: S | ((context: SchemaContext) => S);
|
||||
};
|
||||
|
||||
type ContentCollectionConfig<S extends BaseSchema> = {
|
||||
type?: 'content';
|
||||
schema?: S | ((context: SchemaContext) => S);
|
||||
loader?: never;
|
||||
};
|
||||
|
||||
export type LiveCollectionConfig<
|
||||
L extends LiveLoader,
|
||||
S extends BaseSchema | undefined = undefined,
|
||||
@@ -103,10 +80,22 @@ export type LiveCollectionConfig<
|
||||
loader: L;
|
||||
};
|
||||
|
||||
export type CollectionConfig<S extends BaseSchema> =
|
||||
| ContentCollectionConfig<S>
|
||||
| DataCollectionConfig<S>
|
||||
| ContentLayerConfig<S>;
|
||||
type LoaderConstraint<TData extends { id: string }> =
|
||||
| Loader
|
||||
| (() =>
|
||||
| Array<TData>
|
||||
| Promise<Array<TData>>
|
||||
| Record<string, Omit<TData, 'id'> & { id?: string }>
|
||||
| Promise<Record<string, Omit<TData, 'id'> & { id?: string }>>);
|
||||
|
||||
export type CollectionConfig<
|
||||
TSchema extends BaseSchema,
|
||||
TLoader extends LoaderConstraint<{ id: string }>,
|
||||
> = {
|
||||
type?: 'content_layer';
|
||||
schema?: TSchema | ((context: SchemaContext) => TSchema);
|
||||
loader: TLoader;
|
||||
};
|
||||
|
||||
export function defineLiveCollection<
|
||||
L extends LiveLoader,
|
||||
@@ -167,9 +156,10 @@ export function defineLiveCollection<
|
||||
return config;
|
||||
}
|
||||
|
||||
export function defineCollection<S extends BaseSchema>(
|
||||
config: CollectionConfig<S>,
|
||||
): CollectionConfig<S> {
|
||||
export function defineCollection<
|
||||
TSchema extends BaseSchema,
|
||||
TLoader extends LoaderConstraint<{ id: string }>,
|
||||
>(config: CollectionConfig<TSchema, TLoader>): CollectionConfig<TSchema, TLoader> {
|
||||
const importerFilename = getImporterFilename();
|
||||
|
||||
if (importerFilename?.includes('live.config')) {
|
||||
|
||||
@@ -253,8 +253,8 @@ class ContentLayer {
|
||||
|
||||
if (!schema && typeof collection.loader === 'object') {
|
||||
schema = collection.loader.schema;
|
||||
if (typeof schema === 'function') {
|
||||
schema = await schema();
|
||||
if (!schema && collection.loader.createSchema) {
|
||||
({ schema } = await collection.loader.createSchema());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,14 +49,24 @@ export interface LoaderContext {
|
||||
entryTypes: Map<string, ContentEntryType>;
|
||||
}
|
||||
|
||||
export interface Loader {
|
||||
export type Loader = {
|
||||
/** Unique name of the loader, e.g. the npm package name */
|
||||
name: string;
|
||||
/** Do the actual loading of the data */
|
||||
load: (context: LoaderContext) => Promise<void>;
|
||||
/** Optionally, define the schema of the data. Will be overridden by user-defined schema */
|
||||
schema?: ZodSchema | Promise<ZodSchema> | (() => ZodSchema | Promise<ZodSchema>);
|
||||
}
|
||||
} & (
|
||||
| {
|
||||
/** Optionally, define the schema of the data. Will be overridden by user-defined schema */
|
||||
schema?: ZodSchema;
|
||||
}
|
||||
| {
|
||||
/** Optionally, provide a function to dynamically provide a schema. Will be overridden by user-defined schema */
|
||||
createSchema?: () => Promise<{
|
||||
schema: ZodSchema;
|
||||
types: string;
|
||||
}>;
|
||||
}
|
||||
);
|
||||
|
||||
export interface LoadEntryContext<TEntryFilter = never> {
|
||||
filter: TEntryFilter extends never ? { id: string } : TEntryFilter;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PluginContext } from 'rollup';
|
||||
import { z } from 'zod';
|
||||
import type { ImageMetadata, OmitBrand } from '../assets/types.js';
|
||||
import { emitImageMetadata } from '../assets/utils/node/emitAsset.js';
|
||||
import { emitImageMetadata } from '../assets/utils/node.js';
|
||||
|
||||
export function createImage(
|
||||
pluginContext: PluginContext,
|
||||
|
||||
@@ -2,7 +2,13 @@ import type fsMod from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import colors from 'piccolore';
|
||||
import { normalizePath, type ViteDevServer } from 'vite';
|
||||
import {
|
||||
type DevEnvironment,
|
||||
isRunnableDevEnvironment,
|
||||
normalizePath,
|
||||
type RunnableDevEnvironment,
|
||||
type ViteDevServer,
|
||||
} from 'vite';
|
||||
import { type ZodSchema, z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { AstroError } from '../core/errors/errors.js';
|
||||
@@ -11,6 +17,7 @@ import type { Logger } from '../core/logger/core.js';
|
||||
import { isRelativePath } from '../core/path.js';
|
||||
import type { AstroSettings } from '../types/astro.js';
|
||||
import type { ContentEntryType } from '../types/public/content.js';
|
||||
import type { InjectedType } from '../types/public/integrations.js';
|
||||
import {
|
||||
COLLECTIONS_DIR,
|
||||
CONTENT_LAYER_TYPE,
|
||||
@@ -33,6 +40,7 @@ import {
|
||||
getEntryType,
|
||||
reloadContentConfigObserver,
|
||||
} from './utils.js';
|
||||
import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../core/constants.js';
|
||||
|
||||
type ChokidarEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir';
|
||||
type RawContentEvent = { name: ChokidarEvent; entry: string };
|
||||
@@ -126,7 +134,13 @@ export async function createContentTypesGenerator({
|
||||
return { shouldGenerateTypes: false };
|
||||
}
|
||||
if (fileType === 'config') {
|
||||
await reloadContentConfigObserver({ fs, settings, viteServer });
|
||||
await reloadContentConfigObserver({
|
||||
fs,
|
||||
settings,
|
||||
environment: viteServer.environments[
|
||||
ASTRO_VITE_ENVIRONMENT_NAMES.astro
|
||||
] as RunnableDevEnvironment,
|
||||
});
|
||||
return { shouldGenerateTypes: true };
|
||||
}
|
||||
|
||||
@@ -158,7 +172,7 @@ export async function createContentTypesGenerator({
|
||||
}
|
||||
const collectionInfo = collectionEntryMap[collectionKey];
|
||||
if (collectionInfo.type === 'content') {
|
||||
viteServer.hot.send({
|
||||
viteServer.environments.client.hot.send({
|
||||
type: 'error',
|
||||
err: new AstroError({
|
||||
...AstroErrorData.MixedContentDataCollectionError,
|
||||
@@ -202,7 +216,7 @@ export async function createContentTypesGenerator({
|
||||
}
|
||||
const collectionInfo = collectionEntryMap[collectionKey];
|
||||
if (collectionInfo.type === 'data') {
|
||||
viteServer.hot.send({
|
||||
viteServer.environments.client.hot.send({
|
||||
type: 'error',
|
||||
err: new AstroError({
|
||||
...AstroErrorData.MixedContentDataCollectionError,
|
||||
@@ -309,7 +323,10 @@ export async function createContentTypesGenerator({
|
||||
logger,
|
||||
settings,
|
||||
});
|
||||
invalidateVirtualMod(viteServer);
|
||||
if (!isRunnableDevEnvironment(viteServer.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr])) {
|
||||
return;
|
||||
}
|
||||
invalidateVirtualMod(viteServer.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr]);
|
||||
}
|
||||
}
|
||||
return { init, queueEvent };
|
||||
@@ -317,11 +334,11 @@ export async function createContentTypesGenerator({
|
||||
|
||||
// The virtual module contains a lookup map from slugs to content imports.
|
||||
// Invalidate whenever content types change.
|
||||
function invalidateVirtualMod(viteServer: ViteDevServer) {
|
||||
const virtualMod = viteServer.moduleGraph.getModuleById('\0' + VIRTUAL_MODULE_ID);
|
||||
function invalidateVirtualMod(environment: DevEnvironment) {
|
||||
const virtualMod = environment.moduleGraph.getModuleById('\0' + VIRTUAL_MODULE_ID);
|
||||
if (!virtualMod) return;
|
||||
|
||||
viteServer.moduleGraph.invalidateModule(virtualMod);
|
||||
environment.moduleGraph.invalidateModule(virtualMod);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -342,13 +359,13 @@ function normalizeConfigPath(from: string, to: string) {
|
||||
return `"${isRelativePath(configPath) ? '' : './'}${normalizedPath}"` as const;
|
||||
}
|
||||
|
||||
const schemaCache = new Map<string, ZodSchema>();
|
||||
const createSchemaResultCache = new Map<string, { schema: ZodSchema; types: string }>();
|
||||
|
||||
async function getContentLayerSchema<T extends keyof ContentConfig['collections']>(
|
||||
async function getCreateSchemaResult<T extends keyof ContentConfig['collections']>(
|
||||
collection: ContentConfig['collections'][T],
|
||||
collectionKey: T,
|
||||
): Promise<ZodSchema | undefined> {
|
||||
const cached = schemaCache.get(collectionKey);
|
||||
) {
|
||||
const cached = createSchemaResultCache.get(collectionKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
@@ -356,44 +373,54 @@ async function getContentLayerSchema<T extends keyof ContentConfig['collections'
|
||||
if (
|
||||
collection?.type === CONTENT_LAYER_TYPE &&
|
||||
typeof collection.loader === 'object' &&
|
||||
collection.loader.schema
|
||||
!collection.loader.schema &&
|
||||
collection.loader.createSchema
|
||||
) {
|
||||
let schema = collection.loader.schema;
|
||||
if (typeof schema === 'function') {
|
||||
schema = await schema();
|
||||
}
|
||||
if (schema) {
|
||||
schemaCache.set(collectionKey, await schema);
|
||||
return schema;
|
||||
}
|
||||
const result = await collection.loader.createSchema();
|
||||
createSchemaResultCache.set(collectionKey, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
async function getContentLayerSchema<T extends keyof ContentConfig['collections']>(
|
||||
collection: ContentConfig['collections'][T],
|
||||
collectionKey: T,
|
||||
): Promise<ZodSchema | undefined> {
|
||||
if (collection?.type !== CONTENT_LAYER_TYPE || typeof collection.loader === 'function') {
|
||||
return;
|
||||
}
|
||||
if (collection.loader.schema) {
|
||||
return collection.loader.schema;
|
||||
}
|
||||
const result = await getCreateSchemaResult(collection, collectionKey);
|
||||
return result?.schema;
|
||||
}
|
||||
|
||||
async function typeForCollection<T extends keyof ContentConfig['collections']>(
|
||||
collection: ContentConfig['collections'][T] | undefined,
|
||||
collectionKey: T,
|
||||
): Promise<string> {
|
||||
): Promise<{ type: string; injectedType?: InjectedType }> {
|
||||
if (collection?.schema) {
|
||||
return `InferEntrySchema<${collectionKey}>`;
|
||||
return { type: `InferEntrySchema<${collectionKey}>` };
|
||||
}
|
||||
if (!collection?.type) {
|
||||
return 'any';
|
||||
if (!collection?.type || typeof collection.loader === 'function') {
|
||||
return { type: 'any' };
|
||||
}
|
||||
const schema = await getContentLayerSchema(collection, collectionKey);
|
||||
if (!schema) {
|
||||
return 'any';
|
||||
if (collection.loader.schema) {
|
||||
return { type: `InferLoaderSchema<${collectionKey}>` };
|
||||
}
|
||||
try {
|
||||
const zodToTs = await import('zod-to-ts');
|
||||
const ast = zodToTs.zodToTs(schema);
|
||||
return zodToTs.printNode(ast.node);
|
||||
} catch (err: any) {
|
||||
// zod-to-ts is sad if we don't have TypeScript installed, but that's fine as we won't be needing types in that case
|
||||
if (err.message.includes("Cannot find package 'typescript'")) {
|
||||
return 'any';
|
||||
}
|
||||
throw err;
|
||||
const result = await getCreateSchemaResult(collection, collectionKey);
|
||||
if (!result) {
|
||||
return { type: 'any' };
|
||||
}
|
||||
const base = `loaders/${collectionKey.slice(1, -1)}`;
|
||||
return {
|
||||
type: `import("./${base}.js").Entry`,
|
||||
injectedType: {
|
||||
filename: `${base}.ts`,
|
||||
content: result.types,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function writeContentFiles({
|
||||
@@ -413,7 +440,7 @@ async function writeContentFiles({
|
||||
typeTemplateContent: string;
|
||||
contentEntryTypes: Pick<ContentEntryType, 'contentModuleTypes'>[];
|
||||
contentConfig?: ContentConfig;
|
||||
viteServer: Pick<ViteDevServer, 'hot'>;
|
||||
viteServer: ViteDevServer;
|
||||
logger: Logger;
|
||||
settings: AstroSettings;
|
||||
}) {
|
||||
@@ -439,7 +466,7 @@ async function writeContentFiles({
|
||||
collectionConfig.type !== CONTENT_LAYER_TYPE &&
|
||||
collection.type !== collectionConfig.type
|
||||
) {
|
||||
viteServer.hot.send({
|
||||
viteServer.environments.client.hot.send({
|
||||
type: 'error',
|
||||
err: new AstroError({
|
||||
...AstroErrorData.ContentCollectionTypeMismatchError,
|
||||
@@ -460,7 +487,21 @@ async function writeContentFiles({
|
||||
return;
|
||||
}
|
||||
|
||||
const dataType = await typeForCollection(collectionConfig, collectionKey);
|
||||
const { type: dataType, injectedType } = await typeForCollection(
|
||||
collectionConfig,
|
||||
collectionKey,
|
||||
);
|
||||
|
||||
if (injectedType) {
|
||||
if (settings.injectedTypes.some((t) => t.filename === CONTENT_TYPES_FILE)) {
|
||||
// If it's the first time, we inject types the usual way. sync() will handle creating files and references. If it's not the first time, we just override the dts content
|
||||
const url = new URL(injectedType.filename, settings.dotAstroDir);
|
||||
await fs.promises.mkdir(path.dirname(fileURLToPath(url)), { recursive: true });
|
||||
await fs.promises.writeFile(url, injectedType.content, 'utf-8');
|
||||
} else {
|
||||
settings.injectedTypes.push(injectedType);
|
||||
}
|
||||
}
|
||||
|
||||
dataTypesStr += `${collectionKey}: Record<string, {\n id: string;\n body?: string;\n collection: ${collectionKey};\n data: ${dataType};\n rendered?: RenderedContent;\n filePath?: string;\n}>;\n`;
|
||||
|
||||
@@ -487,7 +528,16 @@ async function writeContentFiles({
|
||||
const key = JSON.parse(collectionKey);
|
||||
|
||||
contentCollectionManifest.collections.push({
|
||||
hasSchema: Boolean(collectionConfig?.schema || schemaCache.has(collectionKey)),
|
||||
hasSchema: Boolean(
|
||||
// Is there a user provided schema or
|
||||
collectionConfig?.schema ||
|
||||
// Is it a loader object and
|
||||
(typeof collectionConfig?.loader !== 'function' &&
|
||||
// Is it a loader static schema or
|
||||
(collectionConfig?.loader.schema ||
|
||||
// is it a loader dynamic schema
|
||||
createSchemaResultCache.has(collectionKey))),
|
||||
),
|
||||
name: key,
|
||||
});
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import { parseFrontmatter } from '@astrojs/markdown-remark';
|
||||
import { slug as githubSlug } from 'github-slugger';
|
||||
import colors from 'piccolore';
|
||||
import type { PluginContext } from 'rollup';
|
||||
import type { ViteDevServer } from 'vite';
|
||||
import type { RunnableDevEnvironment } from 'vite';
|
||||
import xxhash from 'xxhash-wasm';
|
||||
import { z } from 'zod';
|
||||
import { type ZodSchema, z } from 'zod';
|
||||
import { AstroError, AstroErrorData, errorMap, MarkdownError } from '../core/errors/index.js';
|
||||
import { isYAMLException } from '../core/errors/utils.js';
|
||||
import type { Logger } from '../core/logger/core.js';
|
||||
@@ -60,14 +60,39 @@ const collectionConfigParser = z.union([
|
||||
z.function(),
|
||||
z.object({
|
||||
name: z.string(),
|
||||
load: z.function().args(z.custom<LoaderContext>()).returns(
|
||||
z.custom<{
|
||||
schema?: any;
|
||||
types?: string;
|
||||
} | void>(),
|
||||
),
|
||||
schema: z.any().optional(),
|
||||
render: z.function(z.tuple([z.any()], z.unknown())).optional(),
|
||||
load: z.function().args(z.custom<LoaderContext>()).returns(z.promise(z.void())),
|
||||
schema: z
|
||||
.any()
|
||||
.transform((v) => {
|
||||
if (typeof v === 'function') {
|
||||
console.warn(
|
||||
`Your loader's schema is defined using a function. This is no longer supported and the schema will be ignored. Please update your loader to use the \`createSchema()\` utility instead, or report this to the loader author. In a future major version, this will cause the loader to break entirely.`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
return v;
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
if (v !== undefined && !('_def' in v)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid Zod schema',
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
})
|
||||
.optional(),
|
||||
createSchema: z
|
||||
.function()
|
||||
.returns(
|
||||
z.promise(
|
||||
z.object({
|
||||
schema: z.custom<ZodSchema>((v) => '_def' in v),
|
||||
types: z.string(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
@@ -448,25 +473,24 @@ export function isDeferredModule(viteId: string): boolean {
|
||||
async function loadContentConfig({
|
||||
fs,
|
||||
settings,
|
||||
viteServer,
|
||||
environment,
|
||||
}: {
|
||||
fs: typeof fsMod;
|
||||
settings: AstroSettings;
|
||||
viteServer: ViteDevServer;
|
||||
environment: RunnableDevEnvironment;
|
||||
}): Promise<ContentConfig | undefined> {
|
||||
const contentPaths = getContentPaths(settings.config, fs);
|
||||
let unparsedConfig;
|
||||
if (!contentPaths.config.exists) {
|
||||
return undefined;
|
||||
}
|
||||
const configPathname = fileURLToPath(contentPaths.config.url);
|
||||
unparsedConfig = await viteServer.ssrLoadModule(configPathname);
|
||||
const unparsedConfig = await environment.runner.import(configPathname);
|
||||
|
||||
const config = contentConfigParser.safeParse(unparsedConfig);
|
||||
if (config.success) {
|
||||
// Generate a digest of the config file so we can invalidate the cache if it changes
|
||||
const hasher = await xxhash();
|
||||
const digest = await hasher.h64ToString(await fs.promises.readFile(configPathname, 'utf-8'));
|
||||
const digest = hasher.h64ToString(await fs.promises.readFile(configPathname, 'utf-8'));
|
||||
return { ...config.data, digest };
|
||||
} else {
|
||||
const message = config.error.issues
|
||||
@@ -497,7 +521,7 @@ export async function reloadContentConfigObserver({
|
||||
}: {
|
||||
fs: typeof fsMod;
|
||||
settings: AstroSettings;
|
||||
viteServer: ViteDevServer;
|
||||
environment: RunnableDevEnvironment;
|
||||
observer?: ContentObservable;
|
||||
}) {
|
||||
observer.set({ status: 'loading' });
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { extname } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import type { Plugin } from 'vite';
|
||||
import { getAssetsPrefix } from '../assets/utils/getAssetsPrefix.js';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type * as vite from 'vite';
|
||||
import { isRunnableDevEnvironment, type Plugin, type RunnableDevEnvironment } from 'vite';
|
||||
import type { BuildInternals } from '../core/build/internal.js';
|
||||
import type { AstroBuildPlugin } from '../core/build/plugin.js';
|
||||
import type { StaticBuildOptions } from '../core/build/types.js';
|
||||
import { AstroError, AstroErrorData } from '../core/errors/index.js';
|
||||
import type { ModuleLoader } from '../core/module-loader/loader.js';
|
||||
import type { ModuleLoader } from '../core/module-loader/index.js';
|
||||
import { createViteLoader } from '../core/module-loader/vite.js';
|
||||
import { joinPaths, prependForwardSlash } from '../core/path.js';
|
||||
import { wrapId } from '../core/util.js';
|
||||
import type { AstroSettings } from '../types/astro.js';
|
||||
import { getStylesForURL } from '../vite-plugin-astro-server/css.js';
|
||||
import { isBuildableCSSRequest } from '../vite-plugin-astro-server/util.js';
|
||||
import { crawlGraph } from '../vite-plugin-astro-server/vite.js';
|
||||
import {
|
||||
CONTENT_IMAGE_FLAG,
|
||||
CONTENT_RENDER_FLAG,
|
||||
@@ -19,6 +18,8 @@ import {
|
||||
STYLES_PLACEHOLDER,
|
||||
} from './consts.js';
|
||||
import { hasContentFlag } from './utils.js';
|
||||
import { joinPaths, prependForwardSlash, slash } from '@astrojs/internal-helpers/path';
|
||||
import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../core/constants.js';
|
||||
|
||||
export function astroContentAssetPropagationPlugin({
|
||||
settings,
|
||||
@@ -65,7 +66,13 @@ export function astroContentAssetPropagationPlugin({
|
||||
}
|
||||
},
|
||||
configureServer(server) {
|
||||
devModuleLoader = createViteLoader(server);
|
||||
if (!isRunnableDevEnvironment(server.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr])) {
|
||||
return;
|
||||
}
|
||||
devModuleLoader = createViteLoader(
|
||||
server,
|
||||
server.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr] as RunnableDevEnvironment,
|
||||
);
|
||||
},
|
||||
async transform(_, id, options) {
|
||||
if (hasContentFlag(id, PROPAGATED_ASSET_FLAG)) {
|
||||
@@ -82,7 +89,7 @@ export function astroContentAssetPropagationPlugin({
|
||||
styles,
|
||||
urls,
|
||||
crawledFiles: styleCrawledFiles,
|
||||
} = await getStylesForURL(pathToFileURL(basePath), devModuleLoader);
|
||||
} = await getStylesForURL(basePath, devModuleLoader.getSSREnvironment());
|
||||
|
||||
// Register files we crawled to be able to retrieve the rendered styles and scripts,
|
||||
// as when they get updated, we need to re-transform ourselves.
|
||||
@@ -121,64 +128,144 @@ export function astroContentAssetPropagationPlugin({
|
||||
};
|
||||
}
|
||||
|
||||
export function astroConfigBuildPlugin(
|
||||
options: StaticBuildOptions,
|
||||
internals: BuildInternals,
|
||||
): AstroBuildPlugin {
|
||||
return {
|
||||
targets: ['server'],
|
||||
hooks: {
|
||||
'build:post': ({ ssrOutputs, mutate }) => {
|
||||
const outputs = ssrOutputs.flatMap((o) => o.output);
|
||||
const prependBase = (src: string) => {
|
||||
const { assetsPrefix } = options.settings.config.build;
|
||||
if (assetsPrefix) {
|
||||
const fileExtension = extname(src);
|
||||
const pf = getAssetsPrefix(fileExtension, assetsPrefix);
|
||||
return joinPaths(pf, src);
|
||||
interface ImportedDevStyle {
|
||||
id: string;
|
||||
url: string;
|
||||
content: string;
|
||||
}
|
||||
const INLINE_QUERY_REGEX = /(?:\?|&)inline(?:$|&)/;
|
||||
|
||||
/** Given a filePath URL, crawl Vite's module graph to find all style imports. */
|
||||
async function getStylesForURL(
|
||||
filePath: string,
|
||||
environment: RunnableDevEnvironment,
|
||||
): Promise<{ urls: Set<string>; styles: ImportedDevStyle[]; crawledFiles: Set<string> }> {
|
||||
const importedCssUrls = new Set<string>();
|
||||
// Map of url to injected style object. Use a `url` key to deduplicate styles
|
||||
const importedStylesMap = new Map<string, ImportedDevStyle>();
|
||||
const crawledFiles = new Set<string>();
|
||||
|
||||
for await (const importedModule of crawlGraph(environment, filePath, false)) {
|
||||
if (importedModule.file) {
|
||||
crawledFiles.add(importedModule.file);
|
||||
}
|
||||
if (isBuildableCSSRequest(importedModule.url)) {
|
||||
// In dev, we inline all styles if possible
|
||||
let css = '';
|
||||
// If this is a plain CSS module, the default export should be a string
|
||||
if (typeof importedModule.ssrModule?.default === 'string') {
|
||||
css = importedModule.ssrModule.default;
|
||||
}
|
||||
// Else try to load it
|
||||
else {
|
||||
let modId = importedModule.url;
|
||||
// Mark url with ?inline so Vite will return the CSS as plain string, even for CSS modules
|
||||
if (!INLINE_QUERY_REGEX.test(importedModule.url)) {
|
||||
if (importedModule.url.includes('?')) {
|
||||
modId = importedModule.url.replace('?', '?inline&');
|
||||
} else {
|
||||
return prependForwardSlash(joinPaths(options.settings.config.base, src));
|
||||
}
|
||||
};
|
||||
for (const chunk of outputs) {
|
||||
if (chunk.type === 'chunk' && chunk.code.includes(LINKS_PLACEHOLDER)) {
|
||||
const entryStyles = new Set<string>();
|
||||
const entryLinks = new Set<string>();
|
||||
|
||||
for (const id of chunk.moduleIds) {
|
||||
const _entryCss = internals.propagatedStylesMap.get(id);
|
||||
if (_entryCss) {
|
||||
// TODO: Separating styles and links this way is not ideal. The `entryCss` list is order-sensitive
|
||||
// and splitting them into two sets causes the order to be lost, because styles are rendered after
|
||||
// links. Refactor this away in the future.
|
||||
for (const value of _entryCss) {
|
||||
if (value.type === 'inline') entryStyles.add(value.content);
|
||||
if (value.type === 'external') entryLinks.add(value.src);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let newCode = chunk.code;
|
||||
if (entryStyles.size) {
|
||||
newCode = newCode.replace(
|
||||
JSON.stringify(STYLES_PLACEHOLDER),
|
||||
JSON.stringify(Array.from(entryStyles)),
|
||||
);
|
||||
} else {
|
||||
newCode = newCode.replace(JSON.stringify(STYLES_PLACEHOLDER), '[]');
|
||||
}
|
||||
if (entryLinks.size) {
|
||||
newCode = newCode.replace(
|
||||
JSON.stringify(LINKS_PLACEHOLDER),
|
||||
JSON.stringify(Array.from(entryLinks).map(prependBase)),
|
||||
);
|
||||
} else {
|
||||
newCode = newCode.replace(JSON.stringify(LINKS_PLACEHOLDER), '[]');
|
||||
}
|
||||
mutate(chunk, ['server'], newCode);
|
||||
modId += '?inline';
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
try {
|
||||
// The SSR module is possibly not loaded. Load it if it's null.
|
||||
const ssrModule = await environment.runner.import(modId);
|
||||
css = ssrModule.default;
|
||||
} catch {
|
||||
// The module may not be inline-able, e.g. SCSS partials. Skip it as it may already
|
||||
// be inlined into other modules if it happens to be in the graph.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
importedStylesMap.set(importedModule.url, {
|
||||
id: wrapId(importedModule.id ?? importedModule.url),
|
||||
url: wrapId(importedModule.url),
|
||||
content: css,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
urls: importedCssUrls,
|
||||
styles: [...importedStylesMap.values()],
|
||||
crawledFiles,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-build hook that injects propagated styles into content collection chunks.
|
||||
* Finds chunks with LINKS_PLACEHOLDER and STYLES_PLACEHOLDER, and replaces them
|
||||
* with actual styles from propagatedStylesMap.
|
||||
*/
|
||||
export async function contentAssetsBuildPostHook(
|
||||
base: string,
|
||||
internals: BuildInternals,
|
||||
{
|
||||
ssrOutputs,
|
||||
prerenderOutputs,
|
||||
mutate,
|
||||
}: {
|
||||
ssrOutputs: vite.Rollup.RollupOutput[];
|
||||
prerenderOutputs: vite.Rollup.RollupOutput[];
|
||||
mutate: (chunk: vite.Rollup.OutputChunk, envs: ['server'], code: string) => void;
|
||||
},
|
||||
) {
|
||||
// Flatten all output chunks from both SSR and prerender builds
|
||||
const outputs = ssrOutputs
|
||||
.flatMap((o) => o.output)
|
||||
.concat(
|
||||
...(Array.isArray(prerenderOutputs) ? prerenderOutputs : [prerenderOutputs]).flatMap(
|
||||
(o) => o.output,
|
||||
),
|
||||
);
|
||||
|
||||
// Process each chunk that contains placeholder placeholders for styles/links
|
||||
for (const chunk of outputs) {
|
||||
if (chunk.type !== 'chunk') continue;
|
||||
// Skip chunks that don't have content placeholders to inject
|
||||
if (!chunk.code.includes(LINKS_PLACEHOLDER)) continue;
|
||||
|
||||
const entryStyles = new Set<string>();
|
||||
const entryLinks = new Set<string>();
|
||||
|
||||
// For each module in this chunk, look up propagated styles from the map
|
||||
for (const id of chunk.moduleIds) {
|
||||
const entryCss = internals.propagatedStylesMap.get(id);
|
||||
if (entryCss) {
|
||||
// Collect both inline content and external links
|
||||
// TODO: Separating styles and links this way is not ideal. The `entryCss` list is order-sensitive
|
||||
// and splitting them into two sets causes the order to be lost, because styles are rendered after
|
||||
// links. Refactor this away in the future.
|
||||
for (const value of entryCss) {
|
||||
if (value.type === 'inline') entryStyles.add(value.content);
|
||||
if (value.type === 'external')
|
||||
entryLinks.add(prependForwardSlash(joinPaths(base, slash(value.src))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Replace placeholders with actual styles and links
|
||||
let newCode = chunk.code;
|
||||
if (entryStyles.size) {
|
||||
newCode = newCode.replace(
|
||||
JSON.stringify(STYLES_PLACEHOLDER),
|
||||
JSON.stringify(Array.from(entryStyles)),
|
||||
);
|
||||
} else {
|
||||
// Replace with empty array if no styles found
|
||||
newCode = newCode.replace(JSON.stringify(STYLES_PLACEHOLDER), '[]');
|
||||
}
|
||||
if (entryLinks.size) {
|
||||
newCode = newCode.replace(
|
||||
JSON.stringify(LINKS_PLACEHOLDER),
|
||||
JSON.stringify(Array.from(entryLinks)),
|
||||
);
|
||||
} else {
|
||||
// Replace with empty array if no links found
|
||||
newCode = newCode.replace(JSON.stringify(LINKS_PLACEHOLDER), '[]');
|
||||
}
|
||||
// Persist the mutation for writing to disk
|
||||
mutate(chunk as vite.Rollup.OutputChunk, ['server'], newCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { extname } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import * as devalue from 'devalue';
|
||||
import type { PluginContext } from 'rollup';
|
||||
import type { Plugin } from 'vite';
|
||||
import type { Plugin, RunnableDevEnvironment } from 'vite';
|
||||
import { getProxyCode } from '../assets/utils/proxy.js';
|
||||
import { AstroError } from '../core/errors/errors.js';
|
||||
import { AstroErrorData } from '../core/errors/index.js';
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
reloadContentConfigObserver,
|
||||
reverseSymlink,
|
||||
} from './utils.js';
|
||||
import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../core/constants.js';
|
||||
|
||||
function getContentRendererByViteId(
|
||||
viteId: string,
|
||||
@@ -152,6 +153,8 @@ export const _internal = {
|
||||
configureServer(viteServer) {
|
||||
viteServer.watcher.on('all', async (event, entry) => {
|
||||
if (CHOKIDAR_MODIFIED_EVENTS.includes(event)) {
|
||||
const environment = viteServer.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr];
|
||||
|
||||
const entryType = getEntryType(entry, contentPaths, contentEntryExts, dataEntryExts);
|
||||
if (!COLLECTION_TYPES_TO_INVALIDATE_ON.includes(entryType)) return;
|
||||
|
||||
@@ -159,21 +162,27 @@ export const _internal = {
|
||||
// Reload the config in case of changes.
|
||||
// Changes to the config file itself are handled in types-generator.ts, so we skip them here
|
||||
if (entryType === 'content' || entryType === 'data') {
|
||||
await reloadContentConfigObserver({ fs, settings, viteServer });
|
||||
await reloadContentConfigObserver({
|
||||
fs,
|
||||
settings,
|
||||
environment: viteServer.environments[
|
||||
ASTRO_VITE_ENVIRONMENT_NAMES.astro
|
||||
] as RunnableDevEnvironment,
|
||||
});
|
||||
}
|
||||
|
||||
// Invalidate all content imports and `render()` modules.
|
||||
// TODO: trace `reference()` calls for fine-grained invalidation.
|
||||
for (const modUrl of viteServer.moduleGraph.urlToModuleMap.keys()) {
|
||||
for (const modUrl of environment.moduleGraph.urlToModuleMap.keys()) {
|
||||
if (
|
||||
hasContentFlag(modUrl, CONTENT_FLAG) ||
|
||||
hasContentFlag(modUrl, DATA_FLAG) ||
|
||||
Boolean(getContentRendererByViteId(modUrl, settings))
|
||||
) {
|
||||
try {
|
||||
const mod = await viteServer.moduleGraph.getModuleByUrl(modUrl);
|
||||
const mod = await environment.moduleGraph.getModuleByUrl(modUrl);
|
||||
if (mod) {
|
||||
viteServer.moduleGraph.invalidateModule(mod);
|
||||
environment.moduleGraph.invalidateModule(mod);
|
||||
}
|
||||
} catch (e: any) {
|
||||
// The server may be closed due to a restart caused by this file change
|
||||
|
||||
@@ -22,18 +22,20 @@ import {
|
||||
} from './consts.js';
|
||||
import { getDataStoreFile } from './content-layer.js';
|
||||
import { getContentPaths, isDeferredModule } from './utils.js';
|
||||
import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../core/constants.js';
|
||||
|
||||
interface AstroContentVirtualModPluginParams {
|
||||
settings: AstroSettings;
|
||||
fs: typeof nodeFs;
|
||||
}
|
||||
|
||||
function invalidateDataStore(server: ViteDevServer) {
|
||||
const module = server.moduleGraph.getModuleById(RESOLVED_DATA_STORE_VIRTUAL_ID);
|
||||
function invalidateDataStore(viteServer: ViteDevServer) {
|
||||
const environment = viteServer.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr];
|
||||
const module = environment.moduleGraph.getModuleById(RESOLVED_DATA_STORE_VIRTUAL_ID);
|
||||
if (module) {
|
||||
server.moduleGraph.invalidateModule(module);
|
||||
environment.moduleGraph.invalidateModule(module);
|
||||
}
|
||||
server.ws.send({
|
||||
viteServer.environments.client.hot.send({
|
||||
type: 'full-reload',
|
||||
path: '*',
|
||||
});
|
||||
|
||||
11
packages/astro/src/core/app/app.ts
Normal file
11
packages/astro/src/core/app/app.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { BaseApp } from './base.js';
|
||||
import { AppPipeline } from './pipeline.js';
|
||||
|
||||
export class App extends BaseApp {
|
||||
createPipeline(streaming: boolean): AppPipeline {
|
||||
return AppPipeline.create({
|
||||
manifest: this.manifest,
|
||||
streaming,
|
||||
});
|
||||
}
|
||||
}
|
||||
645
packages/astro/src/core/app/base.ts
Normal file
645
packages/astro/src/core/app/base.ts
Normal file
@@ -0,0 +1,645 @@
|
||||
import {
|
||||
appendForwardSlash,
|
||||
collapseDuplicateTrailingSlashes,
|
||||
hasFileExtension,
|
||||
isInternalPath,
|
||||
joinPaths,
|
||||
prependForwardSlash,
|
||||
removeTrailingForwardSlash,
|
||||
} from '@astrojs/internal-helpers/path';
|
||||
import { matchPattern } from '../../assets/utils/index.js';
|
||||
import { normalizeTheLocale } from '../../i18n/index.js';
|
||||
import type { RoutesList } from '../../types/astro.js';
|
||||
import type { RemotePattern, RouteData } from '../../types/public/index.js';
|
||||
import type { Pipeline } from '../base-pipeline.js';
|
||||
import {
|
||||
clientAddressSymbol,
|
||||
DEFAULT_404_COMPONENT,
|
||||
REROUTABLE_STATUS_CODES,
|
||||
REROUTE_DIRECTIVE_HEADER,
|
||||
responseSentSymbol,
|
||||
} from '../constants.js';
|
||||
import { getSetCookiesFromResponse } from '../cookies/index.js';
|
||||
import { AstroError, AstroErrorData } from '../errors/index.js';
|
||||
import { consoleLogDestination } from '../logger/console.js';
|
||||
import { AstroIntegrationLogger, Logger } from '../logger/core.js';
|
||||
import { type CreateRenderContext, RenderContext } from '../render-context.js';
|
||||
import { redirectTemplate } from '../routing/3xx.js';
|
||||
import { ensure404Route } from '../routing/astro-designed-error-pages.js';
|
||||
import { matchRoute } from '../routing/match.js';
|
||||
import { type AstroSession, PERSIST_SYMBOL } from '../session.js';
|
||||
import type { AppPipeline } from './pipeline.js';
|
||||
import type { SSRManifest } from './types.js';
|
||||
|
||||
export interface RenderOptions {
|
||||
/**
|
||||
* Whether to automatically add all cookies written by `Astro.cookie.set()` to the response headers.
|
||||
*
|
||||
* When set to `true`, they will be added to the `Set-Cookie` header as comma-separated key=value pairs. You can use the standard `response.headers.getSetCookie()` API to read them individually.
|
||||
*
|
||||
* When set to `false`, the cookies will only be available from `App.getSetCookieFromResponse(response)`.
|
||||
*
|
||||
* @default {false}
|
||||
*/
|
||||
addCookieHeader?: boolean;
|
||||
|
||||
/**
|
||||
* The client IP address that will be made available as `Astro.clientAddress` in pages, and as `ctx.clientAddress` in API routes and middleware.
|
||||
*
|
||||
* Default: `request[Symbol.for("astro.clientAddress")]`
|
||||
*/
|
||||
clientAddress?: string;
|
||||
|
||||
/**
|
||||
* The mutable object that will be made available as `Astro.locals` in pages, and as `ctx.locals` in API routes and middleware.
|
||||
*/
|
||||
locals?: object;
|
||||
|
||||
/**
|
||||
* A custom fetch function for retrieving prerendered pages - 404 or 500.
|
||||
*
|
||||
* If not provided, Astro will fallback to its default behavior for fetching error pages.
|
||||
*
|
||||
* When a dynamic route is matched but ultimately results in a 404, this function will be used
|
||||
* to fetch the prerendered 404 page if available. Similarly, it may be used to fetch a
|
||||
* prerendered 500 error page when necessary.
|
||||
*
|
||||
* @param {ErrorPagePath} url - The URL of the prerendered 404 or 500 error page to fetch.
|
||||
* @returns {Promise<Response>} A promise resolving to the prerendered response.
|
||||
*/
|
||||
prerenderedErrorPageFetch?: (url: ErrorPagePath) => Promise<Response>;
|
||||
|
||||
/**
|
||||
* **Advanced API**: you probably do not need to use this.
|
||||
*
|
||||
* Default: `app.match(request)`
|
||||
*/
|
||||
routeData?: RouteData;
|
||||
}
|
||||
|
||||
export interface RenderErrorOptions {
|
||||
locals?: App.Locals;
|
||||
routeData?: RouteData;
|
||||
response?: Response;
|
||||
status: 404 | 500;
|
||||
/**
|
||||
* Whether to skip middleware while rendering the error page. Defaults to false.
|
||||
*/
|
||||
skipMiddleware?: boolean;
|
||||
/**
|
||||
* Allows passing an error to 500.astro. It will be available through `Astro.props.error`.
|
||||
*/
|
||||
error?: unknown;
|
||||
clientAddress: string | undefined;
|
||||
prerenderedErrorPageFetch: ((url: ErrorPagePath) => Promise<Response>) | undefined;
|
||||
}
|
||||
|
||||
type ErrorPagePath =
|
||||
| `${string}/404`
|
||||
| `${string}/500`
|
||||
| `${string}/404/`
|
||||
| `${string}/500/`
|
||||
| `${string}404.html`
|
||||
| `${string}500.html`;
|
||||
|
||||
export abstract class BaseApp<P extends Pipeline = AppPipeline> {
|
||||
manifest: SSRManifest;
|
||||
manifestData: RoutesList;
|
||||
pipeline: P;
|
||||
adapterLogger: AstroIntegrationLogger;
|
||||
baseWithoutTrailingSlash: string;
|
||||
logger: Logger;
|
||||
constructor(manifest: SSRManifest, streaming = true, ...args: any[]) {
|
||||
this.manifest = manifest;
|
||||
this.manifestData = { routes: manifest.routes.map((route) => route.routeData) };
|
||||
this.baseWithoutTrailingSlash = removeTrailingForwardSlash(manifest.base);
|
||||
this.pipeline = this.createPipeline(streaming, manifest, ...args);
|
||||
this.logger = new Logger({
|
||||
dest: consoleLogDestination,
|
||||
level: manifest.logLevel,
|
||||
});
|
||||
this.adapterLogger = new AstroIntegrationLogger(this.logger.options, manifest.adapterName);
|
||||
// This is necessary to allow running middlewares for 404 in SSR. There's special handling
|
||||
// to return the host 404 if the user doesn't provide a custom 404
|
||||
ensure404Route(this.manifestData);
|
||||
}
|
||||
|
||||
async createRenderContext(payload: CreateRenderContext): Promise<RenderContext> {
|
||||
return RenderContext.create(payload);
|
||||
}
|
||||
|
||||
getAdapterLogger(): AstroIntegrationLogger {
|
||||
return this.adapterLogger;
|
||||
}
|
||||
|
||||
getAllowedDomains() {
|
||||
return this.manifest.allowedDomains;
|
||||
}
|
||||
|
||||
protected matchesAllowedDomains(forwardedHost: string, protocol?: string): boolean {
|
||||
return BaseApp.validateForwardedHost(forwardedHost, this.manifest.allowedDomains, protocol);
|
||||
}
|
||||
|
||||
static validateForwardedHost(
|
||||
forwardedHost: string,
|
||||
allowedDomains?: Partial<RemotePattern>[],
|
||||
protocol?: string,
|
||||
): boolean {
|
||||
if (!allowedDomains || allowedDomains.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const testUrl = new URL(`${protocol || 'https'}://${forwardedHost}`);
|
||||
return allowedDomains.some((pattern) => {
|
||||
return matchPattern(testUrl, pattern);
|
||||
});
|
||||
} catch {
|
||||
// Invalid URL
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a pipeline by reading the stored manifest
|
||||
*
|
||||
* @param streaming
|
||||
* @param manifest
|
||||
* @param args
|
||||
* @private
|
||||
*/
|
||||
abstract createPipeline(streaming: boolean, manifest: SSRManifest, ...args: any[]): P;
|
||||
|
||||
set setManifestData(newManifestData: RoutesList) {
|
||||
this.manifestData = newManifestData;
|
||||
}
|
||||
|
||||
public removeBase(pathname: string) {
|
||||
if (pathname.startsWith(this.manifest.base)) {
|
||||
return pathname.slice(this.baseWithoutTrailingSlash.length + 1);
|
||||
}
|
||||
return pathname;
|
||||
}
|
||||
|
||||
/**
|
||||
* It removes the base from the request URL, prepends it with a forward slash and attempts to decoded it.
|
||||
*
|
||||
* If the decoding fails, it logs the error and return the pathname as is.
|
||||
* @param request
|
||||
*/
|
||||
public getPathnameFromRequest(request: Request): string {
|
||||
const url = new URL(request.url);
|
||||
const pathname = prependForwardSlash(this.removeBase(url.pathname));
|
||||
try {
|
||||
return decodeURI(pathname);
|
||||
} catch (e: any) {
|
||||
this.getAdapterLogger().error(e.toString());
|
||||
return pathname;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a `Request`, it returns the `RouteData` that matches its `pathname`. By default, prerendered
|
||||
* routes aren't returned, even if they are matched.
|
||||
*
|
||||
* When `allowPrerenderedRoutes` is `true`, the function returns matched prerendered routes too.
|
||||
* @param request
|
||||
* @param allowPrerenderedRoutes
|
||||
*/
|
||||
public match(request: Request, allowPrerenderedRoutes = false): RouteData | undefined {
|
||||
const url = new URL(request.url);
|
||||
// ignore requests matching public assets
|
||||
if (this.manifest.assets.has(url.pathname)) return undefined;
|
||||
let pathname = this.computePathnameFromDomain(request);
|
||||
if (!pathname) {
|
||||
pathname = prependForwardSlash(this.removeBase(url.pathname));
|
||||
}
|
||||
let routeData = matchRoute(decodeURI(pathname), this.manifestData);
|
||||
if (!routeData) return undefined;
|
||||
if (allowPrerenderedRoutes) {
|
||||
return routeData;
|
||||
}
|
||||
// missing routes fall-through, pre rendered are handled by static layer
|
||||
else if (routeData.prerender) {
|
||||
return undefined;
|
||||
}
|
||||
return routeData;
|
||||
}
|
||||
|
||||
private computePathnameFromDomain(request: Request): string | undefined {
|
||||
let pathname: string | undefined = undefined;
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (
|
||||
this.manifest.i18n &&
|
||||
(this.manifest.i18n.strategy === 'domains-prefix-always' ||
|
||||
this.manifest.i18n.strategy === 'domains-prefix-other-locales' ||
|
||||
this.manifest.i18n.strategy === 'domains-prefix-always-no-redirect')
|
||||
) {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host
|
||||
let host = request.headers.get('X-Forwarded-Host');
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto
|
||||
let protocol = request.headers.get('X-Forwarded-Proto');
|
||||
if (protocol) {
|
||||
// this header doesn't have a colon at the end, so we add to be in line with URL#protocol, which does have it
|
||||
protocol = protocol + ':';
|
||||
} else {
|
||||
// we fall back to the protocol of the request
|
||||
protocol = url.protocol;
|
||||
}
|
||||
if (!host) {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Host
|
||||
host = request.headers.get('Host');
|
||||
}
|
||||
// If we don't have a host and a protocol, it's impossible to proceed
|
||||
if (host && protocol) {
|
||||
// The header might have a port in their name, so we remove it
|
||||
host = host.split(':')[0];
|
||||
try {
|
||||
let locale;
|
||||
const hostAsUrl = new URL(`${protocol}//${host}`);
|
||||
for (const [domainKey, localeValue] of Object.entries(
|
||||
this.manifest.i18n.domainLookupTable,
|
||||
)) {
|
||||
// This operation should be safe because we force the protocol via zod inside the configuration
|
||||
// If not, then it means that the manifest was tampered
|
||||
const domainKeyAsUrl = new URL(domainKey);
|
||||
|
||||
if (
|
||||
hostAsUrl.host === domainKeyAsUrl.host &&
|
||||
hostAsUrl.protocol === domainKeyAsUrl.protocol
|
||||
) {
|
||||
locale = localeValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (locale) {
|
||||
pathname = prependForwardSlash(
|
||||
joinPaths(normalizeTheLocale(locale), this.removeBase(url.pathname)),
|
||||
);
|
||||
if (url.pathname.endsWith('/')) {
|
||||
pathname = appendForwardSlash(pathname);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.logger.error(
|
||||
'router',
|
||||
`Astro tried to parse ${protocol}//${host} as an URL, but it threw a parsing error. Check the X-Forwarded-Host and X-Forwarded-Proto headers.`,
|
||||
);
|
||||
this.logger.error('router', `Error: ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return pathname;
|
||||
}
|
||||
|
||||
private redirectTrailingSlash(pathname: string): string {
|
||||
const { trailingSlash } = this.manifest;
|
||||
|
||||
// Ignore root and internal paths
|
||||
if (pathname === '/' || isInternalPath(pathname)) {
|
||||
return pathname;
|
||||
}
|
||||
|
||||
// Redirect multiple trailing slashes to collapsed path
|
||||
const path = collapseDuplicateTrailingSlashes(pathname, trailingSlash !== 'never');
|
||||
if (path !== pathname) {
|
||||
return path;
|
||||
}
|
||||
|
||||
if (trailingSlash === 'ignore') {
|
||||
return pathname;
|
||||
}
|
||||
|
||||
if (trailingSlash === 'always' && !hasFileExtension(pathname)) {
|
||||
return appendForwardSlash(pathname);
|
||||
}
|
||||
if (trailingSlash === 'never') {
|
||||
return removeTrailingForwardSlash(pathname);
|
||||
}
|
||||
|
||||
return pathname;
|
||||
}
|
||||
|
||||
public async render(request: Request, renderOptions?: RenderOptions): Promise<Response> {
|
||||
let routeData: RouteData | undefined = renderOptions?.routeData;
|
||||
let locals: object | undefined;
|
||||
let clientAddress: string | undefined;
|
||||
let addCookieHeader: boolean | undefined;
|
||||
const url = new URL(request.url);
|
||||
const redirect = this.redirectTrailingSlash(url.pathname);
|
||||
const prerenderedErrorPageFetch = renderOptions?.prerenderedErrorPageFetch ?? fetch;
|
||||
|
||||
if (redirect !== url.pathname) {
|
||||
const status = request.method === 'GET' ? 301 : 308;
|
||||
return new Response(
|
||||
redirectTemplate({
|
||||
status,
|
||||
relativeLocation: url.pathname,
|
||||
absoluteLocation: redirect,
|
||||
from: request.url,
|
||||
}),
|
||||
{
|
||||
status,
|
||||
headers: {
|
||||
location: redirect + url.search,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
addCookieHeader = renderOptions?.addCookieHeader;
|
||||
clientAddress = renderOptions?.clientAddress ?? Reflect.get(request, clientAddressSymbol);
|
||||
routeData = renderOptions?.routeData;
|
||||
locals = renderOptions?.locals;
|
||||
|
||||
if (routeData) {
|
||||
this.logger.debug(
|
||||
'router',
|
||||
'The adapter ' + this.manifest.adapterName + ' provided a custom RouteData for ',
|
||||
request.url,
|
||||
);
|
||||
this.logger.debug('router', 'RouteData:\n' + routeData);
|
||||
}
|
||||
if (locals) {
|
||||
if (typeof locals !== 'object') {
|
||||
const error = new AstroError(AstroErrorData.LocalsNotAnObject);
|
||||
this.logger.error(null, error.stack!);
|
||||
return this.renderError(request, {
|
||||
status: 500,
|
||||
error,
|
||||
clientAddress,
|
||||
prerenderedErrorPageFetch: prerenderedErrorPageFetch,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!routeData) {
|
||||
routeData = this.match(request);
|
||||
this.logger.debug('router', 'Astro matched the following route for ' + request.url);
|
||||
this.logger.debug('router', 'RouteData:\n' + routeData);
|
||||
}
|
||||
// At this point we haven't found a route that matches the request, so we create
|
||||
// a "fake" 404 route, so we can call the RenderContext.render
|
||||
// and hit the middleware, which might be able to return a correct Response.
|
||||
if (!routeData) {
|
||||
routeData = this.manifestData.routes.find(
|
||||
(route) => route.component === '404.astro' || route.component === DEFAULT_404_COMPONENT,
|
||||
);
|
||||
}
|
||||
if (!routeData) {
|
||||
this.logger.debug('router', "Astro hasn't found routes that match " + request.url);
|
||||
this.logger.debug('router', "Here's the available routes:\n", this.manifestData);
|
||||
return this.renderError(request, {
|
||||
locals,
|
||||
status: 404,
|
||||
clientAddress,
|
||||
prerenderedErrorPageFetch: prerenderedErrorPageFetch,
|
||||
});
|
||||
}
|
||||
const pathname = this.getPathnameFromRequest(request);
|
||||
const defaultStatus = this.getDefaultStatusCode(routeData, pathname);
|
||||
|
||||
let response;
|
||||
let session: AstroSession | undefined;
|
||||
try {
|
||||
// Load route module. We also catch its error here if it fails on initialization
|
||||
const componentInstance = await this.pipeline.getComponentByRoute(routeData);
|
||||
const renderContext = await this.createRenderContext({
|
||||
pipeline: this.pipeline,
|
||||
locals,
|
||||
pathname,
|
||||
request,
|
||||
routeData,
|
||||
status: defaultStatus,
|
||||
clientAddress,
|
||||
});
|
||||
session = renderContext.session;
|
||||
response = await renderContext.render(componentInstance);
|
||||
} catch (err: any) {
|
||||
this.logger.error(null, err.stack || err.message || String(err));
|
||||
return this.renderError(request, {
|
||||
locals,
|
||||
status: 500,
|
||||
error: err,
|
||||
clientAddress,
|
||||
prerenderedErrorPageFetch: prerenderedErrorPageFetch,
|
||||
});
|
||||
} finally {
|
||||
await session?.[PERSIST_SYMBOL]();
|
||||
}
|
||||
|
||||
if (
|
||||
REROUTABLE_STATUS_CODES.includes(response.status) &&
|
||||
response.headers.get(REROUTE_DIRECTIVE_HEADER) !== 'no'
|
||||
) {
|
||||
return this.renderError(request, {
|
||||
locals,
|
||||
response,
|
||||
status: response.status as 404 | 500,
|
||||
// We don't have an error to report here. Passing null means we pass nothing intentionally
|
||||
// while undefined means there's no error
|
||||
error: response.status === 500 ? null : undefined,
|
||||
clientAddress,
|
||||
prerenderedErrorPageFetch: prerenderedErrorPageFetch,
|
||||
});
|
||||
}
|
||||
|
||||
// We remove internally-used header before we send the response to the user agent.
|
||||
if (response.headers.has(REROUTE_DIRECTIVE_HEADER)) {
|
||||
response.headers.delete(REROUTE_DIRECTIVE_HEADER);
|
||||
}
|
||||
|
||||
if (addCookieHeader) {
|
||||
for (const setCookieHeaderValue of BaseApp.getSetCookieFromResponse(response)) {
|
||||
response.headers.append('set-cookie', setCookieHeaderValue);
|
||||
}
|
||||
}
|
||||
|
||||
Reflect.set(response, responseSentSymbol, true);
|
||||
return response;
|
||||
}
|
||||
|
||||
setCookieHeaders(response: Response) {
|
||||
return getSetCookiesFromResponse(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads all the cookies written by `Astro.cookie.set()` onto the passed response.
|
||||
* For example,
|
||||
* ```ts
|
||||
* for (const cookie_ of App.getSetCookieFromResponse(response)) {
|
||||
* const cookie: string = cookie_
|
||||
* }
|
||||
* ```
|
||||
* @param response The response to read cookies from.
|
||||
* @returns An iterator that yields key-value pairs as equal-sign-separated strings.
|
||||
*/
|
||||
static getSetCookieFromResponse = getSetCookiesFromResponse;
|
||||
|
||||
/**
|
||||
* If it is a known error code, try sending the according page (e.g. 404.astro / 500.astro).
|
||||
* This also handles pre-rendered /404 or /500 routes
|
||||
*/
|
||||
public async renderError(
|
||||
request: Request,
|
||||
{
|
||||
locals,
|
||||
status,
|
||||
response: originalResponse,
|
||||
skipMiddleware = false,
|
||||
error,
|
||||
clientAddress,
|
||||
prerenderedErrorPageFetch,
|
||||
}: RenderErrorOptions,
|
||||
): Promise<Response> {
|
||||
const errorRoutePath = `/${status}${this.manifest.trailingSlash === 'always' ? '/' : ''}`;
|
||||
const errorRouteData = matchRoute(errorRoutePath, this.manifestData);
|
||||
const url = new URL(request.url);
|
||||
if (errorRouteData) {
|
||||
if (errorRouteData.prerender) {
|
||||
const maybeDotHtml = errorRouteData.route.endsWith(`/${status}`) ? '.html' : '';
|
||||
const statusURL = new URL(`${this.baseWithoutTrailingSlash}/${status}${maybeDotHtml}`, url);
|
||||
if (statusURL.toString() !== request.url && prerenderedErrorPageFetch) {
|
||||
const response = await prerenderedErrorPageFetch(statusURL.toString() as ErrorPagePath);
|
||||
|
||||
// In order for the response of the remote to be usable as a response
|
||||
// for this request, it needs to have our status code in the response
|
||||
// instead of the likely successful 200 code it returned when fetching
|
||||
// the error page.
|
||||
//
|
||||
// Furthermore, remote may have returned a compressed page
|
||||
// (the Content-Encoding header was set to e.g. `gzip`). The fetch
|
||||
// implementation in the `mergeResponses` method will make a decoded
|
||||
// response available, so Content-Length and Content-Encoding will
|
||||
// not match the body we provide and need to be removed.
|
||||
const override = { status, removeContentEncodingHeaders: true };
|
||||
|
||||
return this.mergeResponses(response, originalResponse, override);
|
||||
}
|
||||
}
|
||||
const mod = await this.pipeline.getComponentByRoute(errorRouteData);
|
||||
let session: AstroSession | undefined;
|
||||
try {
|
||||
const renderContext = await this.createRenderContext({
|
||||
locals,
|
||||
pipeline: this.pipeline,
|
||||
skipMiddleware,
|
||||
pathname: this.getPathnameFromRequest(request),
|
||||
request,
|
||||
routeData: errorRouteData,
|
||||
status,
|
||||
props: { error },
|
||||
clientAddress,
|
||||
});
|
||||
session = renderContext.session;
|
||||
const response = await renderContext.render(mod);
|
||||
return this.mergeResponses(response, originalResponse);
|
||||
} catch {
|
||||
// Middleware may be the cause of the error, so we try rendering 404/500.astro without it.
|
||||
if (skipMiddleware === false) {
|
||||
return this.renderError(request, {
|
||||
locals,
|
||||
status,
|
||||
response: originalResponse,
|
||||
skipMiddleware: true,
|
||||
clientAddress,
|
||||
prerenderedErrorPageFetch,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await session?.[PERSIST_SYMBOL]();
|
||||
}
|
||||
}
|
||||
|
||||
const response = this.mergeResponses(new Response(null, { status }), originalResponse);
|
||||
Reflect.set(response, responseSentSymbol, true);
|
||||
return response;
|
||||
}
|
||||
|
||||
private mergeResponses(
|
||||
newResponse: Response,
|
||||
originalResponse?: Response,
|
||||
override?: {
|
||||
status: 404 | 500;
|
||||
removeContentEncodingHeaders: boolean;
|
||||
},
|
||||
) {
|
||||
let newResponseHeaders = newResponse.headers;
|
||||
|
||||
// In order to set the body of a remote response as the new response body, we need to remove
|
||||
// headers about encoding in transit, as Node's standard fetch implementation `undici`
|
||||
// currently does not do so.
|
||||
//
|
||||
// Also see https://github.com/nodejs/undici/issues/2514
|
||||
if (override?.removeContentEncodingHeaders) {
|
||||
// The original headers are immutable, so we need to clone them here.
|
||||
newResponseHeaders = new Headers(newResponseHeaders);
|
||||
|
||||
newResponseHeaders.delete('Content-Encoding');
|
||||
newResponseHeaders.delete('Content-Length');
|
||||
}
|
||||
|
||||
if (!originalResponse) {
|
||||
if (override !== undefined) {
|
||||
return new Response(newResponse.body, {
|
||||
status: override.status,
|
||||
statusText: newResponse.statusText,
|
||||
headers: newResponseHeaders,
|
||||
});
|
||||
}
|
||||
return newResponse;
|
||||
}
|
||||
|
||||
// If the new response did not have a meaningful status, an override may have been provided
|
||||
// If the original status was 200 (default), override it with the new status (probably 404 or 500)
|
||||
// Otherwise, the user set a specific status while rendering and we should respect that one
|
||||
const status = override?.status
|
||||
? override.status
|
||||
: originalResponse.status === 200
|
||||
? newResponse.status
|
||||
: originalResponse.status;
|
||||
|
||||
try {
|
||||
// this function could throw an error...
|
||||
originalResponse.headers.delete('Content-type');
|
||||
} catch {}
|
||||
// we use a map to remove duplicates
|
||||
const mergedHeaders = new Map([
|
||||
...Array.from(newResponseHeaders),
|
||||
...Array.from(originalResponse.headers),
|
||||
]);
|
||||
const newHeaders = new Headers();
|
||||
for (const [name, value] of mergedHeaders) {
|
||||
newHeaders.set(name, value);
|
||||
}
|
||||
return new Response(newResponse.body, {
|
||||
status,
|
||||
statusText: status === 200 ? newResponse.statusText : originalResponse.statusText,
|
||||
// If you're looking at here for possible bugs, it means that it's not a bug.
|
||||
// With the middleware, users can meddle with headers, and we should pass to the 404/500.
|
||||
// If users see something weird, it's because they are setting some headers they should not.
|
||||
//
|
||||
// Although, we don't want it to replace the content-type, because the error page must return `text/html`
|
||||
headers: newHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
getDefaultStatusCode(routeData: RouteData, pathname: string): number {
|
||||
if (!routeData.pattern.test(pathname)) {
|
||||
for (const fallbackRoute of routeData.fallbackRoutes) {
|
||||
if (fallbackRoute.pattern.test(pathname)) {
|
||||
return 302;
|
||||
}
|
||||
}
|
||||
}
|
||||
const route = removeTrailingForwardSlash(routeData.route);
|
||||
if (route.endsWith('/404')) return 404;
|
||||
if (route.endsWith('/500')) return 500;
|
||||
return 200;
|
||||
}
|
||||
|
||||
public getManifest() {
|
||||
return this.pipeline.manifest;
|
||||
}
|
||||
}
|
||||
@@ -1,39 +1,82 @@
|
||||
import { decodeKey } from '../encryption.js';
|
||||
import { NOOP_MIDDLEWARE_FN } from '../middleware/noop-middleware.js';
|
||||
import { deserializeRouteData } from '../routing/manifest/serialization.js';
|
||||
import type { RouteInfo, SerializedSSRManifest, SSRManifest } from './types.js';
|
||||
import type { AstroConfig } from '../../types/public/index.js';
|
||||
import type { SSRManifest } from './types.js';
|
||||
|
||||
export function deserializeManifest(serializedManifest: SerializedSSRManifest): SSRManifest {
|
||||
const routes: RouteInfo[] = [];
|
||||
for (const serializedRoute of serializedManifest.routes) {
|
||||
routes.push({
|
||||
...serializedRoute,
|
||||
routeData: deserializeRouteData(serializedRoute.routeData),
|
||||
});
|
||||
|
||||
const route = serializedRoute as unknown as RouteInfo;
|
||||
route.routeData = deserializeRouteData(serializedRoute.routeData);
|
||||
export type RoutingStrategies =
|
||||
| 'manual'
|
||||
| 'pathname-prefix-always'
|
||||
| 'pathname-prefix-other-locales'
|
||||
| 'pathname-prefix-always-no-redirect'
|
||||
| 'domains-prefix-always'
|
||||
| 'domains-prefix-other-locales'
|
||||
| 'domains-prefix-always-no-redirect';
|
||||
export function toRoutingStrategy(
|
||||
routing: NonNullable<AstroConfig['i18n']>['routing'],
|
||||
domains: NonNullable<AstroConfig['i18n']>['domains'],
|
||||
): RoutingStrategies {
|
||||
let strategy: RoutingStrategies;
|
||||
const hasDomains = domains ? Object.keys(domains).length > 0 : false;
|
||||
if (routing === 'manual') {
|
||||
strategy = 'manual';
|
||||
} else {
|
||||
if (!hasDomains) {
|
||||
if (routing?.prefixDefaultLocale === true) {
|
||||
if (routing.redirectToDefaultLocale) {
|
||||
strategy = 'pathname-prefix-always';
|
||||
} else {
|
||||
strategy = 'pathname-prefix-always-no-redirect';
|
||||
}
|
||||
} else {
|
||||
strategy = 'pathname-prefix-other-locales';
|
||||
}
|
||||
} else {
|
||||
if (routing?.prefixDefaultLocale === true) {
|
||||
if (routing.redirectToDefaultLocale) {
|
||||
strategy = 'domains-prefix-always';
|
||||
} else {
|
||||
strategy = 'domains-prefix-always-no-redirect';
|
||||
}
|
||||
} else {
|
||||
strategy = 'domains-prefix-other-locales';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const assets = new Set<string>(serializedManifest.assets);
|
||||
const componentMetadata = new Map(serializedManifest.componentMetadata);
|
||||
const inlinedScripts = new Map(serializedManifest.inlinedScripts);
|
||||
const clientDirectives = new Map(serializedManifest.clientDirectives);
|
||||
const serverIslandNameMap = new Map(serializedManifest.serverIslandNameMap);
|
||||
const key = decodeKey(serializedManifest.key);
|
||||
|
||||
return {
|
||||
// in case user middleware exists, this no-op middleware will be reassigned (see plugin-ssr.ts)
|
||||
middleware() {
|
||||
return { onRequest: NOOP_MIDDLEWARE_FN };
|
||||
},
|
||||
...serializedManifest,
|
||||
assets,
|
||||
componentMetadata,
|
||||
inlinedScripts,
|
||||
clientDirectives,
|
||||
routes,
|
||||
serverIslandNameMap,
|
||||
key,
|
||||
};
|
||||
return strategy;
|
||||
}
|
||||
export function toFallbackType(
|
||||
routing: NonNullable<AstroConfig['i18n']>['routing'],
|
||||
): 'redirect' | 'rewrite' {
|
||||
if (routing === 'manual') {
|
||||
return 'rewrite';
|
||||
}
|
||||
return routing.fallbackType;
|
||||
}
|
||||
|
||||
const PREFIX_DEFAULT_LOCALE = new Set([
|
||||
'pathname-prefix-always',
|
||||
'domains-prefix-always',
|
||||
'pathname-prefix-always-no-redirect',
|
||||
'domains-prefix-always-no-redirect',
|
||||
]);
|
||||
|
||||
const REDIRECT_TO_DEFAULT_LOCALE = new Set([
|
||||
'pathname-prefix-always-no-redirect',
|
||||
'domains-prefix-always-no-redirect',
|
||||
]);
|
||||
|
||||
export function fromRoutingStrategy(
|
||||
strategy: RoutingStrategies,
|
||||
fallbackType: NonNullable<SSRManifest['i18n']>['fallbackType'],
|
||||
): NonNullable<AstroConfig['i18n']>['routing'] {
|
||||
let routing: NonNullable<AstroConfig['i18n']>['routing'];
|
||||
if (strategy === 'manual') {
|
||||
routing = 'manual';
|
||||
} else {
|
||||
routing = {
|
||||
prefixDefaultLocale: PREFIX_DEFAULT_LOCALE.has(strategy),
|
||||
redirectToDefaultLocale: !REDIRECT_TO_DEFAULT_LOCALE.has(strategy),
|
||||
fallbackType,
|
||||
};
|
||||
}
|
||||
return routing;
|
||||
}
|
||||
|
||||
111
packages/astro/src/core/app/dev/app.ts
Normal file
111
packages/astro/src/core/app/dev/app.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { RoutesList } from '../../../types/astro.js';
|
||||
import type { RouteData } from '../../../types/public/index.js';
|
||||
import { MiddlewareNoDataOrNextCalled, MiddlewareNotAResponse } from '../../errors/errors-data.js';
|
||||
import { type AstroError, isAstroError } from '../../errors/index.js';
|
||||
import type { Logger } from '../../logger/core.js';
|
||||
import type { CreateRenderContext, RenderContext } from '../../render-context.js';
|
||||
import { isRoute404, isRoute500 } from '../../routing/match.js';
|
||||
import { BaseApp, type RenderErrorOptions } from '../base.js';
|
||||
import type { SSRManifest } from '../types.js';
|
||||
import { DevPipeline } from './pipeline.js';
|
||||
|
||||
export class DevApp extends BaseApp<DevPipeline> {
|
||||
logger: Logger;
|
||||
currentRenderContext: RenderContext | undefined = undefined;
|
||||
constructor(manifest: SSRManifest, streaming = true, logger: Logger) {
|
||||
super(manifest, streaming, logger);
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
createPipeline(streaming: boolean, manifest: SSRManifest, logger: Logger): DevPipeline {
|
||||
return DevPipeline.create({
|
||||
logger,
|
||||
manifest,
|
||||
streaming,
|
||||
});
|
||||
}
|
||||
|
||||
match(request: Request): RouteData | undefined {
|
||||
return super.match(request, true);
|
||||
}
|
||||
|
||||
async createRenderContext(payload: CreateRenderContext): Promise<RenderContext> {
|
||||
this.currentRenderContext = await super.createRenderContext(payload);
|
||||
return this.currentRenderContext;
|
||||
}
|
||||
|
||||
async renderError(
|
||||
request: Request,
|
||||
{ locals, skipMiddleware = false, error, clientAddress, status }: RenderErrorOptions,
|
||||
): Promise<Response> {
|
||||
// we always throw when we have Astro errors around the middleware
|
||||
if (
|
||||
isAstroError(error) &&
|
||||
[MiddlewareNoDataOrNextCalled.name, MiddlewareNotAResponse.name].includes(error.name)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const renderRoute = async (routeData: RouteData) => {
|
||||
try {
|
||||
const preloadedComponent = await this.pipeline.getComponentByRoute(routeData);
|
||||
const renderContext = await this.createRenderContext({
|
||||
locals,
|
||||
pipeline: this.pipeline,
|
||||
pathname: this.getPathnameFromRequest(request),
|
||||
skipMiddleware,
|
||||
request,
|
||||
routeData,
|
||||
clientAddress,
|
||||
status,
|
||||
shouldInjectCspMetaTags: false,
|
||||
});
|
||||
renderContext.props.error = error;
|
||||
const response = await renderContext.render(preloadedComponent);
|
||||
|
||||
if (error) {
|
||||
// Log useful information that the custom 500 page may not display unlike the default error overlay
|
||||
this.logger.error('router', (error as AstroError).stack || (error as AstroError).message);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (_err) {
|
||||
if (skipMiddleware === false) {
|
||||
return this.renderError(request, {
|
||||
clientAddress: undefined,
|
||||
prerenderedErrorPageFetch: fetch,
|
||||
status: 500,
|
||||
skipMiddleware: true,
|
||||
error: _err,
|
||||
});
|
||||
}
|
||||
// If even skipping the middleware isn't enough to prevent the error, show the dev overlay
|
||||
throw _err;
|
||||
}
|
||||
};
|
||||
|
||||
if (status === 404) {
|
||||
const custom400 = getCustom400Route(this.manifestData);
|
||||
if (custom400) {
|
||||
return renderRoute(custom400);
|
||||
}
|
||||
}
|
||||
|
||||
const custom500 = getCustom500Route(this.manifestData);
|
||||
|
||||
// Show dev overlay
|
||||
if (!custom500) {
|
||||
throw error;
|
||||
} else {
|
||||
return renderRoute(custom500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCustom500Route(manifestData: RoutesList): RouteData | undefined {
|
||||
return manifestData.routes.find((r) => isRoute500(r.route));
|
||||
}
|
||||
|
||||
function getCustom400Route(manifestData: RoutesList): RouteData | undefined {
|
||||
return manifestData.routes.find((r) => isRoute404(r.route));
|
||||
}
|
||||
147
packages/astro/src/core/app/dev/pipeline.ts
Normal file
147
packages/astro/src/core/app/dev/pipeline.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { ComponentInstance, ImportedDevStyle } from '../../../types/astro.js';
|
||||
import type {
|
||||
DevToolbarMetadata,
|
||||
RewritePayload,
|
||||
RouteData,
|
||||
SSRElement,
|
||||
} from '../../../types/public/index.js';
|
||||
import { type HeadElements, Pipeline, type TryRewriteResult } from '../../base-pipeline.js';
|
||||
import { ASTRO_VERSION } from '../../constants.js';
|
||||
import { createModuleScriptElement, createStylesheetElementSet } from '../../render/ssr-element.js';
|
||||
import { findRouteToRewrite } from '../../routing/rewrite.js';
|
||||
|
||||
type DevPipelineCreate = Pick<DevPipeline, 'logger' | 'manifest' | 'streaming'>;
|
||||
|
||||
export class DevPipeline extends Pipeline {
|
||||
getName(): string {
|
||||
return 'DevPipeline';
|
||||
}
|
||||
|
||||
static create({ logger, manifest, streaming }: DevPipelineCreate) {
|
||||
async function resolve(specifier: string): Promise<string> {
|
||||
if (specifier.startsWith('/')) {
|
||||
return specifier;
|
||||
} else {
|
||||
return '/@id/' + specifier;
|
||||
}
|
||||
}
|
||||
|
||||
const pipeline = new DevPipeline(
|
||||
logger,
|
||||
manifest,
|
||||
'development',
|
||||
manifest.renderers,
|
||||
resolve,
|
||||
streaming,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
return pipeline;
|
||||
}
|
||||
|
||||
async headElements(routeData: RouteData): Promise<HeadElements> {
|
||||
const { assetsPrefix, base } = this.manifest;
|
||||
const routeInfo = this.manifest.routes.find((route) => route.routeData === routeData);
|
||||
// may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc.
|
||||
const links = new Set<never>();
|
||||
const scripts = new Set<SSRElement>();
|
||||
const styles = createStylesheetElementSet(routeInfo?.styles ?? [], base, assetsPrefix);
|
||||
|
||||
for (const script of routeInfo?.scripts ?? []) {
|
||||
if ('stage' in script) {
|
||||
if (script.stage === 'head-inline') {
|
||||
scripts.add({
|
||||
props: {},
|
||||
children: script.children,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
scripts.add(createModuleScriptElement(script));
|
||||
}
|
||||
}
|
||||
|
||||
scripts.add({
|
||||
props: { type: 'module', src: '/@vite/client' },
|
||||
children: '',
|
||||
});
|
||||
|
||||
if (this.manifest.devToolbar.enabled) {
|
||||
scripts.add({
|
||||
props: {
|
||||
type: 'module',
|
||||
src: '/@id/astro/runtime/client/dev-toolbar/entrypoint.js',
|
||||
},
|
||||
children: '',
|
||||
});
|
||||
|
||||
const additionalMetadata: DevToolbarMetadata['__astro_dev_toolbar__'] = {
|
||||
root: this.manifest.rootDir.toString(),
|
||||
version: ASTRO_VERSION,
|
||||
latestAstroVersion: this.manifest.devToolbar.latestAstroVersion,
|
||||
debugInfo: this.manifest.devToolbar.debugInfoOutput ?? '',
|
||||
};
|
||||
|
||||
// Additional data for the dev overlay
|
||||
const children = `window.__astro_dev_toolbar__ = ${JSON.stringify(additionalMetadata)}`;
|
||||
scripts.add({ props: {}, children });
|
||||
}
|
||||
|
||||
const { devCSSMap } = await import('virtual:astro:dev-css-all');
|
||||
|
||||
const importer = devCSSMap.get(routeData.component);
|
||||
let css = new Set<ImportedDevStyle>();
|
||||
if(importer) {
|
||||
const cssModule = await importer();
|
||||
css = cssModule.css;
|
||||
} else {
|
||||
this.logger.warn('assets', `Unable to find CSS for ${routeData.component}. This is likely a bug in Astro.`);
|
||||
}
|
||||
|
||||
// Pass framework CSS in as style tags to be appended to the page.
|
||||
for (const { id, url: src, content } of css) {
|
||||
// Vite handles HMR for styles injected as scripts
|
||||
scripts.add({ props: { type: 'module', src }, children: '' });
|
||||
// But we still want to inject the styles to avoid FOUC. The style tags
|
||||
// should emulate what Vite injects so further HMR works as expected.
|
||||
styles.add({ props: { 'data-vite-dev-id': id }, children: content });
|
||||
}
|
||||
|
||||
return { scripts, styles, links };
|
||||
}
|
||||
|
||||
componentMetadata() {}
|
||||
|
||||
async getComponentByRoute(routeData: RouteData): Promise<ComponentInstance> {
|
||||
try {
|
||||
const module = await this.getModuleForRoute(routeData);
|
||||
return module.page();
|
||||
} catch {
|
||||
// could not find, ignore
|
||||
}
|
||||
|
||||
const url = new URL(routeData.component, this.manifest.rootDir);
|
||||
const module = await import(/* @vite-ignore */ url.toString());
|
||||
return module;
|
||||
}
|
||||
|
||||
async tryRewrite(payload: RewritePayload, request: Request): Promise<TryRewriteResult> {
|
||||
const { newUrl, pathname, routeData } = findRouteToRewrite({
|
||||
payload,
|
||||
request,
|
||||
routes: this.manifest?.routes.map((r) => r.routeData),
|
||||
trailingSlash: this.manifest.trailingSlash,
|
||||
buildFormat: this.manifest.buildFormat,
|
||||
base: this.manifest.base,
|
||||
outDir: this.manifest?.serverLike ? this.manifest.buildClientDir : this.manifest.outDir,
|
||||
});
|
||||
|
||||
const componentInstance = await this.getComponentByRoute(routeData);
|
||||
return { newUrl, pathname, componentInstance, routeData };
|
||||
}
|
||||
}
|
||||
14
packages/astro/src/core/app/entrypoint.ts
Normal file
14
packages/astro/src/core/app/entrypoint.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { manifest } from 'virtual:astro:manifest';
|
||||
import { App } from './app.js';
|
||||
import type { BaseApp } from './base.js';
|
||||
import { DevApp } from './dev/app.js';
|
||||
import { createConsoleLogger } from './logging.js';
|
||||
|
||||
export function createApp(dev = import.meta.env.DEV): BaseApp {
|
||||
if (dev) {
|
||||
const logger = createConsoleLogger(manifest.logLevel);
|
||||
return new DevApp(manifest, true, logger);
|
||||
} else {
|
||||
return new App(manifest);
|
||||
}
|
||||
}
|
||||
@@ -1,767 +1,13 @@
|
||||
import {
|
||||
collapseDuplicateTrailingSlashes,
|
||||
hasFileExtension,
|
||||
isInternalPath,
|
||||
} from '@astrojs/internal-helpers/path';
|
||||
import { matchPattern, type RemotePattern } from '../../assets/utils/remotePattern.js';
|
||||
import { normalizeTheLocale } from '../../i18n/index.js';
|
||||
import type { RoutesList } from '../../types/astro.js';
|
||||
import type { RouteData, SSRManifest } from '../../types/public/internal.js';
|
||||
import {
|
||||
clientAddressSymbol,
|
||||
DEFAULT_404_COMPONENT,
|
||||
REROUTABLE_STATUS_CODES,
|
||||
REROUTE_DIRECTIVE_HEADER,
|
||||
responseSentSymbol,
|
||||
} from '../constants.js';
|
||||
import { getSetCookiesFromResponse } from '../cookies/index.js';
|
||||
import { AstroError, AstroErrorData } from '../errors/index.js';
|
||||
import { consoleLogDestination } from '../logger/console.js';
|
||||
import { AstroIntegrationLogger, Logger } from '../logger/core.js';
|
||||
import { NOOP_MIDDLEWARE_FN } from '../middleware/noop-middleware.js';
|
||||
import {
|
||||
appendForwardSlash,
|
||||
joinPaths,
|
||||
prependForwardSlash,
|
||||
removeTrailingForwardSlash,
|
||||
} from '../path.js';
|
||||
import { createAssetLink } from '../render/ssr-element.js';
|
||||
import { RenderContext } from '../render-context.js';
|
||||
import { redirectTemplate } from '../routing/3xx.js';
|
||||
import { ensure404Route } from '../routing/astro-designed-error-pages.js';
|
||||
import { createDefaultRoutes } from '../routing/default.js';
|
||||
import { matchRoute } from '../routing/match.js';
|
||||
import { type AstroSession, PERSIST_SYMBOL } from '../session.js';
|
||||
import { AppPipeline } from './pipeline.js';
|
||||
|
||||
export { deserializeManifest } from './common.js';
|
||||
|
||||
type ErrorPagePath =
|
||||
| `${string}/404`
|
||||
| `${string}/500`
|
||||
| `${string}/404/`
|
||||
| `${string}/500/`
|
||||
| `${string}404.html`
|
||||
| `${string}500.html`;
|
||||
|
||||
export interface RenderOptions {
|
||||
/**
|
||||
* Whether to automatically add all cookies written by `Astro.cookie.set()` to the response headers.
|
||||
*
|
||||
* When set to `true`, they will be added to the `Set-Cookie` header as comma-separated key=value pairs. You can use the standard `response.headers.getSetCookie()` API to read them individually.
|
||||
*
|
||||
* When set to `false`, the cookies will only be available from `App.getSetCookieFromResponse(response)`.
|
||||
*
|
||||
* @default {false}
|
||||
*/
|
||||
addCookieHeader?: boolean;
|
||||
|
||||
/**
|
||||
* The client IP address that will be made available as `Astro.clientAddress` in pages, and as `ctx.clientAddress` in API routes and middleware.
|
||||
*
|
||||
* Default: `request[Symbol.for("astro.clientAddress")]`
|
||||
*/
|
||||
clientAddress?: string;
|
||||
|
||||
/**
|
||||
* The mutable object that will be made available as `Astro.locals` in pages, and as `ctx.locals` in API routes and middleware.
|
||||
*/
|
||||
locals?: object;
|
||||
|
||||
/**
|
||||
* A custom fetch function for retrieving prerendered pages - 404 or 500.
|
||||
*
|
||||
* If not provided, Astro will fallback to its default behavior for fetching error pages.
|
||||
*
|
||||
* When a dynamic route is matched but ultimately results in a 404, this function will be used
|
||||
* to fetch the prerendered 404 page if available. Similarly, it may be used to fetch a
|
||||
* prerendered 500 error page when necessary.
|
||||
*
|
||||
* @param {ErrorPagePath} url - The URL of the prerendered 404 or 500 error page to fetch.
|
||||
* @returns {Promise<Response>} A promise resolving to the prerendered response.
|
||||
*/
|
||||
prerenderedErrorPageFetch?: (url: ErrorPagePath) => Promise<Response>;
|
||||
|
||||
/**
|
||||
* **Advanced API**: you probably do not need to use this.
|
||||
*
|
||||
* Default: `app.match(request)`
|
||||
*/
|
||||
routeData?: RouteData;
|
||||
}
|
||||
|
||||
export interface RenderErrorOptions {
|
||||
locals?: App.Locals;
|
||||
routeData?: RouteData;
|
||||
response?: Response;
|
||||
status: 404 | 500;
|
||||
/**
|
||||
* Whether to skip middleware while rendering the error page. Defaults to false.
|
||||
*/
|
||||
skipMiddleware?: boolean;
|
||||
/**
|
||||
* Allows passing an error to 500.astro. It will be available through `Astro.props.error`.
|
||||
*/
|
||||
error?: unknown;
|
||||
clientAddress: string | undefined;
|
||||
prerenderedErrorPageFetch: (url: ErrorPagePath) => Promise<Response>;
|
||||
}
|
||||
|
||||
export class App {
|
||||
#manifest: SSRManifest;
|
||||
#manifestData: RoutesList;
|
||||
#logger = new Logger({
|
||||
dest: consoleLogDestination,
|
||||
level: 'info',
|
||||
});
|
||||
#baseWithoutTrailingSlash: string;
|
||||
#pipeline: AppPipeline;
|
||||
#adapterLogger: AstroIntegrationLogger;
|
||||
|
||||
constructor(manifest: SSRManifest, streaming = true) {
|
||||
this.#manifest = manifest;
|
||||
this.#manifestData = {
|
||||
routes: manifest.routes.map((route) => route.routeData),
|
||||
};
|
||||
// This is necessary to allow running middlewares for 404 in SSR. There's special handling
|
||||
// to return the host 404 if the user doesn't provide a custom 404
|
||||
ensure404Route(this.#manifestData);
|
||||
this.#baseWithoutTrailingSlash = removeTrailingForwardSlash(this.#manifest.base);
|
||||
this.#pipeline = this.#createPipeline(streaming);
|
||||
this.#adapterLogger = new AstroIntegrationLogger(
|
||||
this.#logger.options,
|
||||
this.#manifest.adapterName,
|
||||
);
|
||||
}
|
||||
|
||||
getAdapterLogger(): AstroIntegrationLogger {
|
||||
return this.#adapterLogger;
|
||||
}
|
||||
|
||||
getAllowedDomains() {
|
||||
return this.#manifest.allowedDomains;
|
||||
}
|
||||
|
||||
protected get manifest(): SSRManifest {
|
||||
return this.#manifest;
|
||||
}
|
||||
|
||||
protected set manifest(value: SSRManifest) {
|
||||
this.#manifest = value;
|
||||
}
|
||||
|
||||
protected matchesAllowedDomains(forwardedHost: string, protocol?: string): boolean {
|
||||
return App.validateForwardedHost(forwardedHost, this.#manifest.allowedDomains, protocol);
|
||||
}
|
||||
|
||||
static validateForwardedHost(
|
||||
forwardedHost: string,
|
||||
allowedDomains?: Partial<RemotePattern>[],
|
||||
protocol?: string,
|
||||
): boolean {
|
||||
if (!allowedDomains || allowedDomains.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const testUrl = new URL(`${protocol || 'https'}://${forwardedHost}`);
|
||||
return allowedDomains.some((pattern) => {
|
||||
return matchPattern(testUrl, pattern);
|
||||
});
|
||||
} catch {
|
||||
// Invalid URL
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a hostname by rejecting any with path separators.
|
||||
* Prevents path injection attacks. Invalid hostnames return undefined.
|
||||
*/
|
||||
static sanitizeHost(hostname: string | undefined): string | undefined {
|
||||
if (!hostname) return undefined;
|
||||
// Reject any hostname containing path separators - they're invalid
|
||||
if (/[/\\]/.test(hostname)) return undefined;
|
||||
return hostname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate forwarded headers (proto, host, port) against allowedDomains.
|
||||
* Returns validated values or undefined for rejected headers.
|
||||
* Uses strict defaults: http/https only for proto, rejects port if not in allowedDomains.
|
||||
*/
|
||||
static validateForwardedHeaders(
|
||||
forwardedProtocol?: string,
|
||||
forwardedHost?: string,
|
||||
forwardedPort?: string,
|
||||
allowedDomains?: Partial<RemotePattern>[],
|
||||
): { protocol?: string; host?: string; port?: string } {
|
||||
const result: { protocol?: string; host?: string; port?: string } = {};
|
||||
|
||||
// Validate protocol
|
||||
if (forwardedProtocol) {
|
||||
if (allowedDomains && allowedDomains.length > 0) {
|
||||
const hasProtocolPatterns = allowedDomains.some(
|
||||
(pattern) => pattern.protocol !== undefined,
|
||||
);
|
||||
if (hasProtocolPatterns) {
|
||||
// Validate against allowedDomains patterns
|
||||
try {
|
||||
const testUrl = new URL(`${forwardedProtocol}://example.com`);
|
||||
const isAllowed = allowedDomains.some((pattern) => matchPattern(testUrl, pattern));
|
||||
if (isAllowed) {
|
||||
result.protocol = forwardedProtocol;
|
||||
}
|
||||
} catch {
|
||||
// Invalid protocol, omit from result
|
||||
}
|
||||
} else if (/^https?$/.test(forwardedProtocol)) {
|
||||
// allowedDomains exist but no protocol patterns, allow http/https
|
||||
result.protocol = forwardedProtocol;
|
||||
}
|
||||
} else if (/^https?$/.test(forwardedProtocol)) {
|
||||
// No allowedDomains, only allow http/https
|
||||
result.protocol = forwardedProtocol;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate port first
|
||||
if (forwardedPort && allowedDomains && allowedDomains.length > 0) {
|
||||
const hasPortPatterns = allowedDomains.some((pattern) => pattern.port !== undefined);
|
||||
if (hasPortPatterns) {
|
||||
// Validate against allowedDomains patterns
|
||||
const isAllowed = allowedDomains.some((pattern) => pattern.port === forwardedPort);
|
||||
if (isAllowed) {
|
||||
result.port = forwardedPort;
|
||||
}
|
||||
}
|
||||
// If no port patterns, reject the header (strict security default)
|
||||
}
|
||||
|
||||
// Validate host (extract port from hostname for validation)
|
||||
// Reject empty strings and sanitize to prevent path injection
|
||||
if (forwardedHost && forwardedHost.length > 0 && allowedDomains && allowedDomains.length > 0) {
|
||||
const protoForValidation = result.protocol || 'https';
|
||||
const sanitized = App.sanitizeHost(forwardedHost);
|
||||
if (sanitized) {
|
||||
try {
|
||||
// Extract hostname without port for validation
|
||||
const hostnameOnly = sanitized.split(':')[0];
|
||||
// Use full hostname:port for validation so patterns with ports match correctly
|
||||
// Include validated port if available, otherwise use port from forwardedHost if present
|
||||
const portFromHost = sanitized.includes(':') ? sanitized.split(':')[1] : undefined;
|
||||
const portForValidation = result.port || portFromHost;
|
||||
const hostWithPort = portForValidation
|
||||
? `${hostnameOnly}:${portForValidation}`
|
||||
: hostnameOnly;
|
||||
const testUrl = new URL(`${protoForValidation}://${hostWithPort}`);
|
||||
const isAllowed = allowedDomains.some((pattern) => matchPattern(testUrl, pattern));
|
||||
if (isAllowed) {
|
||||
result.host = sanitized;
|
||||
}
|
||||
} catch {
|
||||
// Invalid host, omit from result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a pipeline by reading the stored manifest
|
||||
*
|
||||
* @param streaming
|
||||
* @private
|
||||
*/
|
||||
#createPipeline(streaming = false) {
|
||||
return AppPipeline.create({
|
||||
logger: this.#logger,
|
||||
manifest: this.#manifest,
|
||||
runtimeMode: 'production',
|
||||
renderers: this.#manifest.renderers,
|
||||
defaultRoutes: createDefaultRoutes(this.#manifest),
|
||||
resolve: async (specifier: string) => {
|
||||
if (!(specifier in this.#manifest.entryModules)) {
|
||||
throw new Error(`Unable to resolve [${specifier}]`);
|
||||
}
|
||||
const bundlePath = this.#manifest.entryModules[specifier];
|
||||
if (bundlePath.startsWith('data:') || bundlePath.length === 0) {
|
||||
return bundlePath;
|
||||
} else {
|
||||
return createAssetLink(bundlePath, this.#manifest.base, this.#manifest.assetsPrefix);
|
||||
}
|
||||
},
|
||||
serverLike: true,
|
||||
streaming,
|
||||
});
|
||||
}
|
||||
|
||||
removeBase(pathname: string) {
|
||||
if (pathname.startsWith(this.#manifest.base)) {
|
||||
return pathname.slice(this.#baseWithoutTrailingSlash.length + 1);
|
||||
}
|
||||
return pathname;
|
||||
}
|
||||
|
||||
/**
|
||||
* It removes the base from the request URL, prepends it with a forward slash and attempts to decoded it.
|
||||
*
|
||||
* If the decoding fails, it logs the error and return the pathname as is.
|
||||
* @param request
|
||||
* @private
|
||||
*/
|
||||
#getPathnameFromRequest(request: Request): string {
|
||||
const url = new URL(request.url);
|
||||
const pathname = prependForwardSlash(this.removeBase(url.pathname));
|
||||
try {
|
||||
return decodeURI(pathname);
|
||||
} catch (e: any) {
|
||||
this.getAdapterLogger().error(e.toString());
|
||||
return pathname;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a `Request`, it returns the `RouteData` that matches its `pathname`. By default, prerendered
|
||||
* routes aren't returned, even if they are matched.
|
||||
*
|
||||
* When `allowPrerenderedRoutes` is `true`, the function returns matched prerendered routes too.
|
||||
* @param request
|
||||
* @param allowPrerenderedRoutes
|
||||
*/
|
||||
match(request: Request, allowPrerenderedRoutes = false): RouteData | undefined {
|
||||
const url = new URL(request.url);
|
||||
// ignore requests matching public assets
|
||||
if (this.#manifest.assets.has(url.pathname)) return undefined;
|
||||
let pathname = this.#computePathnameFromDomain(request);
|
||||
if (!pathname) {
|
||||
pathname = prependForwardSlash(this.removeBase(url.pathname));
|
||||
}
|
||||
let routeData = matchRoute(decodeURI(pathname), this.#manifestData);
|
||||
|
||||
if (!routeData) return undefined;
|
||||
if (allowPrerenderedRoutes) {
|
||||
return routeData;
|
||||
}
|
||||
// missing routes fall-through, pre rendered are handled by static layer
|
||||
else if (routeData.prerender) {
|
||||
return undefined;
|
||||
}
|
||||
return routeData;
|
||||
}
|
||||
|
||||
#computePathnameFromDomain(request: Request): string | undefined {
|
||||
let pathname: string | undefined = undefined;
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (
|
||||
this.#manifest.i18n &&
|
||||
(this.#manifest.i18n.strategy === 'domains-prefix-always' ||
|
||||
this.#manifest.i18n.strategy === 'domains-prefix-other-locales' ||
|
||||
this.#manifest.i18n.strategy === 'domains-prefix-always-no-redirect')
|
||||
) {
|
||||
// Validate forwarded headers
|
||||
const validated = App.validateForwardedHeaders(
|
||||
request.headers.get('X-Forwarded-Proto') ?? undefined,
|
||||
request.headers.get('X-Forwarded-Host') ?? undefined,
|
||||
request.headers.get('X-Forwarded-Port') ?? undefined,
|
||||
this.#manifest.allowedDomains,
|
||||
);
|
||||
|
||||
// Build protocol with fallback
|
||||
let protocol = validated.protocol ? validated.protocol + ':' : url.protocol;
|
||||
|
||||
// Build host with fallback
|
||||
let host = validated.host ?? request.headers.get('Host');
|
||||
// If we don't have a host and a protocol, it's impossible to proceed
|
||||
if (host && protocol) {
|
||||
// The header might have a port in their name, so we remove it
|
||||
host = host.split(':')[0];
|
||||
try {
|
||||
let locale;
|
||||
const hostAsUrl = new URL(`${protocol}//${host}`);
|
||||
for (const [domainKey, localeValue] of Object.entries(
|
||||
this.#manifest.i18n.domainLookupTable,
|
||||
)) {
|
||||
// This operation should be safe because we force the protocol via zod inside the configuration
|
||||
// If not, then it means that the manifest was tampered
|
||||
const domainKeyAsUrl = new URL(domainKey);
|
||||
|
||||
if (
|
||||
hostAsUrl.host === domainKeyAsUrl.host &&
|
||||
hostAsUrl.protocol === domainKeyAsUrl.protocol
|
||||
) {
|
||||
locale = localeValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (locale) {
|
||||
pathname = prependForwardSlash(
|
||||
joinPaths(normalizeTheLocale(locale), this.removeBase(url.pathname)),
|
||||
);
|
||||
if (url.pathname.endsWith('/')) {
|
||||
pathname = appendForwardSlash(pathname);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.#logger.error(
|
||||
'router',
|
||||
`Astro tried to parse ${protocol}//${host} as an URL, but it threw a parsing error. Check the X-Forwarded-Host and X-Forwarded-Proto headers.`,
|
||||
);
|
||||
this.#logger.error('router', `Error: ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return pathname;
|
||||
}
|
||||
|
||||
#redirectTrailingSlash(pathname: string): string {
|
||||
const { trailingSlash } = this.#manifest;
|
||||
|
||||
// Ignore root and internal paths
|
||||
if (pathname === '/' || isInternalPath(pathname)) {
|
||||
return pathname;
|
||||
}
|
||||
|
||||
// Redirect multiple trailing slashes to collapsed path
|
||||
const path = collapseDuplicateTrailingSlashes(pathname, trailingSlash !== 'never');
|
||||
if (path !== pathname) {
|
||||
return path;
|
||||
}
|
||||
|
||||
if (trailingSlash === 'ignore') {
|
||||
return pathname;
|
||||
}
|
||||
|
||||
if (trailingSlash === 'always' && !hasFileExtension(pathname)) {
|
||||
return appendForwardSlash(pathname);
|
||||
}
|
||||
if (trailingSlash === 'never') {
|
||||
return removeTrailingForwardSlash(pathname);
|
||||
}
|
||||
|
||||
return pathname;
|
||||
}
|
||||
|
||||
async render(
|
||||
request: Request,
|
||||
{
|
||||
addCookieHeader,
|
||||
clientAddress = Reflect.get(request, clientAddressSymbol),
|
||||
locals,
|
||||
prerenderedErrorPageFetch = fetch,
|
||||
routeData,
|
||||
}: RenderOptions = {},
|
||||
): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const redirect = this.#redirectTrailingSlash(url.pathname);
|
||||
|
||||
if (redirect !== url.pathname) {
|
||||
const status = request.method === 'GET' ? 301 : 308;
|
||||
return new Response(
|
||||
redirectTemplate({
|
||||
status,
|
||||
relativeLocation: url.pathname,
|
||||
absoluteLocation: redirect,
|
||||
from: request.url,
|
||||
}),
|
||||
{
|
||||
status,
|
||||
headers: {
|
||||
location: redirect + url.search,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (routeData) {
|
||||
this.#logger.debug(
|
||||
'router',
|
||||
'The adapter ' + this.#manifest.adapterName + ' provided a custom RouteData for ',
|
||||
request.url,
|
||||
);
|
||||
this.#logger.debug('router', 'RouteData:\n' + routeData);
|
||||
}
|
||||
if (locals) {
|
||||
if (typeof locals !== 'object') {
|
||||
const error = new AstroError(AstroErrorData.LocalsNotAnObject);
|
||||
this.#logger.error(null, error.stack!);
|
||||
return this.#renderError(request, {
|
||||
status: 500,
|
||||
error,
|
||||
clientAddress,
|
||||
prerenderedErrorPageFetch: prerenderedErrorPageFetch,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!routeData) {
|
||||
routeData = this.match(request);
|
||||
this.#logger.debug('router', 'Astro matched the following route for ' + request.url);
|
||||
this.#logger.debug('router', 'RouteData:\n' + routeData);
|
||||
}
|
||||
// At this point we haven't found a route that matches the request, so we create
|
||||
// a "fake" 404 route, so we can call the RenderContext.render
|
||||
// and hit the middleware, which might be able to return a correct Response.
|
||||
if (!routeData) {
|
||||
routeData = this.#manifestData.routes.find(
|
||||
(route) => route.component === '404.astro' || route.component === DEFAULT_404_COMPONENT,
|
||||
);
|
||||
}
|
||||
if (!routeData) {
|
||||
this.#logger.debug('router', "Astro hasn't found routes that match " + request.url);
|
||||
this.#logger.debug('router', "Here's the available routes:\n", this.#manifestData);
|
||||
return this.#renderError(request, {
|
||||
locals,
|
||||
status: 404,
|
||||
clientAddress,
|
||||
prerenderedErrorPageFetch: prerenderedErrorPageFetch,
|
||||
});
|
||||
}
|
||||
const pathname = this.#getPathnameFromRequest(request);
|
||||
const defaultStatus = this.#getDefaultStatusCode(routeData, pathname);
|
||||
|
||||
let response;
|
||||
let session: AstroSession | undefined;
|
||||
try {
|
||||
// Load route module. We also catch its error here if it fails on initialization
|
||||
const mod = await this.#pipeline.getModuleForRoute(routeData);
|
||||
|
||||
const renderContext = await RenderContext.create({
|
||||
pipeline: this.#pipeline,
|
||||
locals,
|
||||
pathname,
|
||||
request,
|
||||
routeData,
|
||||
status: defaultStatus,
|
||||
clientAddress,
|
||||
});
|
||||
session = renderContext.session;
|
||||
response = await renderContext.render(await mod.page());
|
||||
} catch (err: any) {
|
||||
this.#logger.error(null, err.stack || err.message || String(err));
|
||||
return this.#renderError(request, {
|
||||
locals,
|
||||
status: 500,
|
||||
error: err,
|
||||
clientAddress,
|
||||
prerenderedErrorPageFetch: prerenderedErrorPageFetch,
|
||||
});
|
||||
} finally {
|
||||
await session?.[PERSIST_SYMBOL]();
|
||||
}
|
||||
|
||||
if (
|
||||
REROUTABLE_STATUS_CODES.includes(response.status) &&
|
||||
response.headers.get(REROUTE_DIRECTIVE_HEADER) !== 'no'
|
||||
) {
|
||||
return this.#renderError(request, {
|
||||
locals,
|
||||
response,
|
||||
status: response.status as 404 | 500,
|
||||
// We don't have an error to report here. Passing null means we pass nothing intentionally
|
||||
// while undefined means there's no error
|
||||
error: response.status === 500 ? null : undefined,
|
||||
clientAddress,
|
||||
prerenderedErrorPageFetch: prerenderedErrorPageFetch,
|
||||
});
|
||||
}
|
||||
|
||||
// We remove internally-used header before we send the response to the user agent.
|
||||
if (response.headers.has(REROUTE_DIRECTIVE_HEADER)) {
|
||||
response.headers.delete(REROUTE_DIRECTIVE_HEADER);
|
||||
}
|
||||
|
||||
if (addCookieHeader) {
|
||||
for (const setCookieHeaderValue of App.getSetCookieFromResponse(response)) {
|
||||
response.headers.append('set-cookie', setCookieHeaderValue);
|
||||
}
|
||||
}
|
||||
|
||||
Reflect.set(response, responseSentSymbol, true);
|
||||
return response;
|
||||
}
|
||||
|
||||
setCookieHeaders(response: Response) {
|
||||
return getSetCookiesFromResponse(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads all the cookies written by `Astro.cookie.set()` onto the passed response.
|
||||
* For example,
|
||||
* ```ts
|
||||
* for (const cookie_ of App.getSetCookieFromResponse(response)) {
|
||||
* const cookie: string = cookie_
|
||||
* }
|
||||
* ```
|
||||
* @param response The response to read cookies from.
|
||||
* @returns An iterator that yields key-value pairs as equal-sign-separated strings.
|
||||
*/
|
||||
static getSetCookieFromResponse = getSetCookiesFromResponse;
|
||||
|
||||
/**
|
||||
* If it is a known error code, try sending the according page (e.g. 404.astro / 500.astro).
|
||||
* This also handles pre-rendered /404 or /500 routes
|
||||
*/
|
||||
async #renderError(
|
||||
request: Request,
|
||||
{
|
||||
locals,
|
||||
status,
|
||||
response: originalResponse,
|
||||
skipMiddleware = false,
|
||||
error,
|
||||
clientAddress,
|
||||
prerenderedErrorPageFetch,
|
||||
}: RenderErrorOptions,
|
||||
): Promise<Response> {
|
||||
const errorRoutePath = `/${status}${this.#manifest.trailingSlash === 'always' ? '/' : ''}`;
|
||||
const errorRouteData = matchRoute(errorRoutePath, this.#manifestData);
|
||||
const url = new URL(request.url);
|
||||
if (errorRouteData) {
|
||||
if (errorRouteData.prerender) {
|
||||
const maybeDotHtml = errorRouteData.route.endsWith(`/${status}`) ? '.html' : '';
|
||||
const statusURL = new URL(
|
||||
`${this.#baseWithoutTrailingSlash}/${status}${maybeDotHtml}`,
|
||||
url,
|
||||
);
|
||||
if (statusURL.toString() !== request.url) {
|
||||
const response = await prerenderedErrorPageFetch(statusURL.toString() as ErrorPagePath);
|
||||
|
||||
// In order for the response of the remote to be usable as a response
|
||||
// for this request, it needs to have our status code in the response
|
||||
// instead of the likely successful 200 code it returned when fetching
|
||||
// the error page.
|
||||
//
|
||||
// Furthermore, remote may have returned a compressed page
|
||||
// (the Content-Encoding header was set to e.g. `gzip`). The fetch
|
||||
// implementation in the `mergeResponses` method will make a decoded
|
||||
// response available, so Content-Length and Content-Encoding will
|
||||
// not match the body we provide and need to be removed.
|
||||
const override = { status, removeContentEncodingHeaders: true };
|
||||
|
||||
return this.#mergeResponses(response, originalResponse, override);
|
||||
}
|
||||
}
|
||||
const mod = await this.#pipeline.getModuleForRoute(errorRouteData);
|
||||
let session: AstroSession | undefined;
|
||||
try {
|
||||
const renderContext = await RenderContext.create({
|
||||
locals,
|
||||
pipeline: this.#pipeline,
|
||||
middleware: skipMiddleware ? NOOP_MIDDLEWARE_FN : undefined,
|
||||
pathname: this.#getPathnameFromRequest(request),
|
||||
request,
|
||||
routeData: errorRouteData,
|
||||
status,
|
||||
props: { error },
|
||||
clientAddress,
|
||||
});
|
||||
session = renderContext.session;
|
||||
const response = await renderContext.render(await mod.page());
|
||||
return this.#mergeResponses(response, originalResponse);
|
||||
} catch {
|
||||
// Middleware may be the cause of the error, so we try rendering 404/500.astro without it.
|
||||
if (skipMiddleware === false) {
|
||||
return this.#renderError(request, {
|
||||
locals,
|
||||
status,
|
||||
response: originalResponse,
|
||||
skipMiddleware: true,
|
||||
clientAddress,
|
||||
prerenderedErrorPageFetch,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await session?.[PERSIST_SYMBOL]();
|
||||
}
|
||||
}
|
||||
|
||||
const response = this.#mergeResponses(new Response(null, { status }), originalResponse);
|
||||
Reflect.set(response, responseSentSymbol, true);
|
||||
return response;
|
||||
}
|
||||
|
||||
#mergeResponses(
|
||||
newResponse: Response,
|
||||
originalResponse?: Response,
|
||||
override?: {
|
||||
status: 404 | 500;
|
||||
removeContentEncodingHeaders: boolean;
|
||||
},
|
||||
) {
|
||||
let newResponseHeaders = newResponse.headers;
|
||||
|
||||
// In order to set the body of a remote response as the new response body, we need to remove
|
||||
// headers about encoding in transit, as Node's standard fetch implementation `undici`
|
||||
// currently does not do so.
|
||||
//
|
||||
// Also see https://github.com/nodejs/undici/issues/2514
|
||||
if (override?.removeContentEncodingHeaders) {
|
||||
// The original headers are immutable, so we need to clone them here.
|
||||
newResponseHeaders = new Headers(newResponseHeaders);
|
||||
|
||||
newResponseHeaders.delete('Content-Encoding');
|
||||
newResponseHeaders.delete('Content-Length');
|
||||
}
|
||||
|
||||
if (!originalResponse) {
|
||||
if (override !== undefined) {
|
||||
return new Response(newResponse.body, {
|
||||
status: override.status,
|
||||
statusText: newResponse.statusText,
|
||||
headers: newResponseHeaders,
|
||||
});
|
||||
}
|
||||
return newResponse;
|
||||
}
|
||||
|
||||
// If the new response did not have a meaningful status, an override may have been provided
|
||||
// If the original status was 200 (default), override it with the new status (probably 404 or 500)
|
||||
// Otherwise, the user set a specific status while rendering and we should respect that one
|
||||
const status = override?.status
|
||||
? override.status
|
||||
: originalResponse.status === 200
|
||||
? newResponse.status
|
||||
: originalResponse.status;
|
||||
|
||||
try {
|
||||
// this function could throw an error...
|
||||
originalResponse.headers.delete('Content-type');
|
||||
} catch {}
|
||||
// we use a map to remove duplicates
|
||||
const mergedHeaders = new Map([
|
||||
...Array.from(newResponseHeaders),
|
||||
...Array.from(originalResponse.headers),
|
||||
]);
|
||||
const newHeaders = new Headers();
|
||||
for (const [name, value] of mergedHeaders) {
|
||||
newHeaders.set(name, value);
|
||||
}
|
||||
return new Response(newResponse.body, {
|
||||
status,
|
||||
statusText: status === 200 ? newResponse.statusText : originalResponse.statusText,
|
||||
// If you're looking at here for possible bugs, it means that it's not a bug.
|
||||
// With the middleware, users can meddle with headers, and we should pass to the 404/500.
|
||||
// If users see something weird, it's because they are setting some headers they should not.
|
||||
//
|
||||
// Although, we don't want it to replace the content-type, because the error page must return `text/html`
|
||||
headers: newHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
#getDefaultStatusCode(routeData: RouteData, pathname: string): number {
|
||||
if (!routeData.pattern.test(pathname)) {
|
||||
for (const fallbackRoute of routeData.fallbackRoutes) {
|
||||
if (fallbackRoute.pattern.test(pathname)) {
|
||||
return 302;
|
||||
}
|
||||
}
|
||||
}
|
||||
const route = removeTrailingForwardSlash(routeData.route);
|
||||
if (route.endsWith('/404')) return 404;
|
||||
if (route.endsWith('/500')) return 500;
|
||||
return 200;
|
||||
}
|
||||
}
|
||||
export type { RoutesList } from '../../types/astro.js';
|
||||
export { App } from './app.js';
|
||||
export { BaseApp, type RenderErrorOptions, type RenderOptions } from './base.js';
|
||||
export { fromRoutingStrategy, toRoutingStrategy } from './common.js';
|
||||
export { createConsoleLogger } from './logging.js';
|
||||
export {
|
||||
deserializeRouteData,
|
||||
deserializeRouteInfo,
|
||||
serializeRouteData,
|
||||
serializeRouteInfo,
|
||||
deserializeManifest
|
||||
} from './manifest.js';
|
||||
export { AppPipeline } from './pipeline.js';
|
||||
|
||||
10
packages/astro/src/core/app/logging.ts
Normal file
10
packages/astro/src/core/app/logging.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { AstroInlineConfig } from '../../types/public/index.js';
|
||||
import { consoleLogDestination } from '../logger/console.js';
|
||||
import { Logger } from '../logger/core.js';
|
||||
|
||||
export function createConsoleLogger(level: AstroInlineConfig['logLevel']): Logger {
|
||||
return new Logger({
|
||||
dest: consoleLogDestination,
|
||||
level: level ?? 'info',
|
||||
});
|
||||
}
|
||||
124
packages/astro/src/core/app/manifest.ts
Normal file
124
packages/astro/src/core/app/manifest.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { SerializedRouteData } from '../../types/astro.js';
|
||||
import type { AstroConfig, RouteData } from '../../types/public/index.js';
|
||||
import type { RoutesList } from '../../types/astro.js';
|
||||
import { decodeKey } from '../encryption.js';
|
||||
import { NOOP_MIDDLEWARE_FN } from '../middleware/noop-middleware.js';
|
||||
import type { RouteInfo, SerializedSSRManifest, SSRManifest, SerializedRouteInfo } from './types.js';
|
||||
|
||||
export function deserializeManifest(
|
||||
serializedManifest: SerializedSSRManifest,
|
||||
routesList?: RoutesList,
|
||||
): SSRManifest {
|
||||
const routes: RouteInfo[] = [];
|
||||
if (serializedManifest.routes) {
|
||||
for (const serializedRoute of serializedManifest.routes) {
|
||||
routes.push({
|
||||
...serializedRoute,
|
||||
routeData: deserializeRouteData(serializedRoute.routeData),
|
||||
});
|
||||
|
||||
const route = serializedRoute as unknown as RouteInfo;
|
||||
route.routeData = deserializeRouteData(serializedRoute.routeData);
|
||||
}
|
||||
}
|
||||
if (routesList) {
|
||||
for (const route of routesList?.routes) {
|
||||
routes.push({
|
||||
file: '',
|
||||
links: [],
|
||||
scripts: [],
|
||||
styles: [],
|
||||
routeData: route,
|
||||
});
|
||||
}
|
||||
}
|
||||
const assets = new Set<string>(serializedManifest.assets);
|
||||
const componentMetadata = new Map(serializedManifest.componentMetadata);
|
||||
const inlinedScripts = new Map(serializedManifest.inlinedScripts);
|
||||
const clientDirectives = new Map(serializedManifest.clientDirectives);
|
||||
const key = decodeKey(serializedManifest.key);
|
||||
|
||||
return {
|
||||
// in case user middleware exists, this no-op middleware will be reassigned (see plugin-ssr.ts)
|
||||
middleware() {
|
||||
return { onRequest: NOOP_MIDDLEWARE_FN };
|
||||
},
|
||||
...serializedManifest,
|
||||
rootDir: new URL(serializedManifest.rootDir),
|
||||
srcDir: new URL(serializedManifest.srcDir),
|
||||
publicDir: new URL(serializedManifest.publicDir),
|
||||
outDir: new URL(serializedManifest.outDir),
|
||||
cacheDir: new URL(serializedManifest.cacheDir),
|
||||
buildClientDir: new URL(serializedManifest.buildClientDir),
|
||||
buildServerDir: new URL(serializedManifest.buildServerDir),
|
||||
assets,
|
||||
componentMetadata,
|
||||
inlinedScripts,
|
||||
clientDirectives,
|
||||
routes,
|
||||
key,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeRouteData(
|
||||
routeData: RouteData,
|
||||
trailingSlash: AstroConfig['trailingSlash'],
|
||||
): SerializedRouteData {
|
||||
return {
|
||||
...routeData,
|
||||
pattern: routeData.pattern.source,
|
||||
redirectRoute: routeData.redirectRoute
|
||||
? serializeRouteData(routeData.redirectRoute, trailingSlash)
|
||||
: undefined,
|
||||
fallbackRoutes: routeData.fallbackRoutes.map((fallbackRoute) => {
|
||||
return serializeRouteData(fallbackRoute, trailingSlash);
|
||||
}),
|
||||
_meta: { trailingSlash },
|
||||
};
|
||||
}
|
||||
|
||||
export function deserializeRouteData(rawRouteData: SerializedRouteData): RouteData {
|
||||
return {
|
||||
route: rawRouteData.route,
|
||||
type: rawRouteData.type,
|
||||
pattern: new RegExp(rawRouteData.pattern),
|
||||
params: rawRouteData.params,
|
||||
component: rawRouteData.component,
|
||||
pathname: rawRouteData.pathname || undefined,
|
||||
segments: rawRouteData.segments,
|
||||
prerender: rawRouteData.prerender,
|
||||
redirect: rawRouteData.redirect,
|
||||
redirectRoute: rawRouteData.redirectRoute
|
||||
? deserializeRouteData(rawRouteData.redirectRoute)
|
||||
: undefined,
|
||||
fallbackRoutes: rawRouteData.fallbackRoutes.map((fallback) => {
|
||||
return deserializeRouteData(fallback);
|
||||
}),
|
||||
isIndex: rawRouteData.isIndex,
|
||||
origin: rawRouteData.origin,
|
||||
distURL: rawRouteData.distURL,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeRouteInfo(
|
||||
routeInfo: RouteInfo,
|
||||
trailingSlash: AstroConfig['trailingSlash'],
|
||||
): SerializedRouteInfo {
|
||||
return {
|
||||
styles: routeInfo.styles,
|
||||
file: routeInfo.file,
|
||||
links: routeInfo.links,
|
||||
scripts: routeInfo.scripts,
|
||||
routeData: serializeRouteData(routeInfo.routeData, trailingSlash),
|
||||
};
|
||||
}
|
||||
|
||||
export function deserializeRouteInfo(rawRouteInfo: SerializedRouteInfo): RouteInfo {
|
||||
return {
|
||||
styles: rawRouteInfo.styles,
|
||||
file: rawRouteInfo.file,
|
||||
links: rawRouteInfo.links,
|
||||
scripts: rawRouteInfo.scripts,
|
||||
routeData: deserializeRouteData(rawRouteInfo.routeData),
|
||||
};
|
||||
}
|
||||
@@ -4,11 +4,12 @@ import { Http2ServerResponse } from 'node:http2';
|
||||
import type { Socket } from 'node:net';
|
||||
import type { RemotePattern } from '../../types/public/config.js';
|
||||
import { clientAddressSymbol, nodeRequestAbortControllerCleanupSymbol } from '../constants.js';
|
||||
import { deserializeManifest } from './common.js';
|
||||
import { deserializeManifest } from './manifest.js';
|
||||
import { createOutgoingHttpHeaders } from './createOutgoingHttpHeaders.js';
|
||||
import type { RenderOptions } from './index.js';
|
||||
import { App } from './index.js';
|
||||
import type { NodeAppHeadersJson, SerializedSSRManifest, SSRManifest } from './types.js';
|
||||
import { sanitizeHost, validateForwardedHeaders } from './validate-forwarded-headers.js';
|
||||
|
||||
/**
|
||||
* Allow the request body to be explicitly overridden. For example, this
|
||||
@@ -81,7 +82,7 @@ export class NodeApp extends App {
|
||||
|
||||
// Validate forwarded headers
|
||||
// NOTE: Header values may have commas/spaces from proxy chains, extract first value
|
||||
const validated = App.validateForwardedHeaders(
|
||||
const validated = validateForwardedHeaders(
|
||||
getFirstForwardedValue(req.headers['x-forwarded-proto']),
|
||||
getFirstForwardedValue(req.headers['x-forwarded-host']),
|
||||
getFirstForwardedValue(req.headers['x-forwarded-port']),
|
||||
@@ -90,7 +91,7 @@ export class NodeApp extends App {
|
||||
|
||||
const protocol = validated.protocol ?? providedProtocol;
|
||||
// validated.host is already sanitized, only sanitize providedHostname
|
||||
const sanitizedProvidedHostname = App.sanitizeHost(
|
||||
const sanitizedProvidedHostname = sanitizeHost(
|
||||
typeof providedHostname === 'string' ? providedHostname : undefined,
|
||||
);
|
||||
const hostname = validated.host ?? sanitizedProvidedHostname;
|
||||
|
||||
@@ -1,40 +1,42 @@
|
||||
import type { ComponentInstance } from '../../types/astro.js';
|
||||
import type { RewritePayload } from '../../types/public/common.js';
|
||||
import type { RouteData, SSRElement, SSRResult } from '../../types/public/internal.js';
|
||||
import { Pipeline, type TryRewriteResult } from '../base-pipeline.js';
|
||||
import type { RouteData, SSRElement } from '../../types/public/internal.js';
|
||||
import { type HeadElements, Pipeline, type TryRewriteResult } from '../base-pipeline.js';
|
||||
import type { SinglePageBuiltModule } from '../build/types.js';
|
||||
import { RedirectSinglePageBuiltModule } from '../redirects/component.js';
|
||||
import { createModuleScriptElement, createStylesheetElementSet } from '../render/ssr-element.js';
|
||||
import { RedirectSinglePageBuiltModule } from '../redirects/index.js';
|
||||
import {
|
||||
createAssetLink,
|
||||
createModuleScriptElement,
|
||||
createStylesheetElementSet,
|
||||
} from '../render/ssr-element.js';
|
||||
import { getFallbackRoute, routeIsFallback, routeIsRedirect } from '../routing/helpers.js';
|
||||
import { findRouteToRewrite } from '../routing/rewrite.js';
|
||||
import { createConsoleLogger } from './logging.js';
|
||||
|
||||
export class AppPipeline extends Pipeline {
|
||||
static create({
|
||||
logger,
|
||||
manifest,
|
||||
runtimeMode,
|
||||
renderers,
|
||||
resolve,
|
||||
serverLike,
|
||||
streaming,
|
||||
defaultRoutes,
|
||||
}: Pick<
|
||||
AppPipeline,
|
||||
| 'logger'
|
||||
| 'manifest'
|
||||
| 'runtimeMode'
|
||||
| 'renderers'
|
||||
| 'resolve'
|
||||
| 'serverLike'
|
||||
| 'streaming'
|
||||
| 'defaultRoutes'
|
||||
>) {
|
||||
getName(): string {
|
||||
return 'AppPipeline';
|
||||
}
|
||||
|
||||
static create({ manifest, streaming }: Pick<AppPipeline, 'manifest' | 'streaming'>) {
|
||||
const resolve = async function resolve(specifier: string) {
|
||||
if (!(specifier in manifest.entryModules)) {
|
||||
throw new Error(`Unable to resolve [${specifier}]`);
|
||||
}
|
||||
const bundlePath = manifest.entryModules[specifier];
|
||||
if (bundlePath.startsWith('data:') || bundlePath.length === 0) {
|
||||
return bundlePath;
|
||||
} else {
|
||||
return createAssetLink(bundlePath, manifest.base, manifest.assetsPrefix);
|
||||
}
|
||||
};
|
||||
const logger = createConsoleLogger(manifest.logLevel);
|
||||
const pipeline = new AppPipeline(
|
||||
logger,
|
||||
manifest,
|
||||
runtimeMode,
|
||||
renderers,
|
||||
'production',
|
||||
manifest.renderers,
|
||||
resolve,
|
||||
serverLike,
|
||||
streaming,
|
||||
undefined,
|
||||
undefined,
|
||||
@@ -44,17 +46,17 @@ export class AppPipeline extends Pipeline {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
defaultRoutes,
|
||||
);
|
||||
return pipeline;
|
||||
}
|
||||
|
||||
headElements(routeData: RouteData): Pick<SSRResult, 'scripts' | 'styles' | 'links'> {
|
||||
async headElements(routeData: RouteData): Promise<HeadElements> {
|
||||
const { assetsPrefix, base } = this.manifest;
|
||||
const routeInfo = this.manifest.routes.find((route) => route.routeData === routeData);
|
||||
// may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc.
|
||||
const links = new Set<never>();
|
||||
const scripts = new Set<SSRElement>();
|
||||
const styles = createStylesheetElementSet(routeInfo?.styles ?? []);
|
||||
const styles = createStylesheetElementSet(routeInfo?.styles ?? [], base, assetsPrefix);
|
||||
|
||||
for (const script of routeInfo?.scripts ?? []) {
|
||||
if ('stage' in script) {
|
||||
@@ -65,7 +67,7 @@ export class AppPipeline extends Pipeline {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
scripts.add(createModuleScriptElement(script));
|
||||
scripts.add(createModuleScriptElement(script, base, assetsPrefix));
|
||||
}
|
||||
}
|
||||
return { links, styles, scripts };
|
||||
@@ -78,6 +80,44 @@ export class AppPipeline extends Pipeline {
|
||||
return module.page();
|
||||
}
|
||||
|
||||
async getModuleForRoute(route: RouteData): Promise<SinglePageBuiltModule> {
|
||||
for (const defaultRoute of this.defaultRoutes) {
|
||||
if (route.component === defaultRoute.component) {
|
||||
return {
|
||||
page: () => Promise.resolve(defaultRoute.instance),
|
||||
};
|
||||
}
|
||||
}
|
||||
let routeToProcess = route;
|
||||
if (routeIsRedirect(route)) {
|
||||
if (route.redirectRoute) {
|
||||
// This is a static redirect
|
||||
routeToProcess = route.redirectRoute;
|
||||
} else {
|
||||
// This is an external redirect, so we return a component stub
|
||||
return RedirectSinglePageBuiltModule;
|
||||
}
|
||||
} else if (routeIsFallback(route)) {
|
||||
// This is a i18n fallback route
|
||||
routeToProcess = getFallbackRoute(route, this.manifest.routes);
|
||||
}
|
||||
|
||||
if (this.manifest.pageMap) {
|
||||
const importComponentInstance = this.manifest.pageMap.get(routeToProcess.component);
|
||||
if (!importComponentInstance) {
|
||||
throw new Error(
|
||||
`Unexpectedly unable to find a component instance for route ${route.route}`,
|
||||
);
|
||||
}
|
||||
return await importComponentInstance();
|
||||
} else if (this.manifest.pageModule) {
|
||||
return this.manifest.pageModule;
|
||||
}
|
||||
throw new Error(
|
||||
"Astro couldn't find the correct page to render, probably because it wasn't correctly mapped for SSR usage. This is an internal error, please file an issue.",
|
||||
);
|
||||
}
|
||||
|
||||
async tryRewrite(payload: RewritePayload, request: Request): Promise<TryRewriteResult> {
|
||||
const { newUrl, pathname, routeData } = findRouteToRewrite({
|
||||
payload,
|
||||
@@ -86,40 +126,10 @@ export class AppPipeline extends Pipeline {
|
||||
trailingSlash: this.manifest.trailingSlash,
|
||||
buildFormat: this.manifest.buildFormat,
|
||||
base: this.manifest.base,
|
||||
outDir: this.serverLike ? this.manifest.buildClientDir : this.manifest.outDir,
|
||||
outDir: this.manifest?.serverLike ? this.manifest.buildClientDir : this.manifest.outDir,
|
||||
});
|
||||
|
||||
const componentInstance = await this.getComponentByRoute(routeData);
|
||||
return { newUrl, pathname, componentInstance, routeData };
|
||||
}
|
||||
|
||||
async getModuleForRoute(route: RouteData): Promise<SinglePageBuiltModule> {
|
||||
for (const defaultRoute of this.defaultRoutes) {
|
||||
if (route.component === defaultRoute.component) {
|
||||
return {
|
||||
page: () => Promise.resolve(defaultRoute.instance),
|
||||
renderers: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (route.type === 'redirect') {
|
||||
return RedirectSinglePageBuiltModule;
|
||||
} else {
|
||||
if (this.manifest.pageMap) {
|
||||
const importComponentInstance = this.manifest.pageMap.get(route.component);
|
||||
if (!importComponentInstance) {
|
||||
throw new Error(
|
||||
`Unexpectedly unable to find a component instance for route ${route.route}`,
|
||||
);
|
||||
}
|
||||
return await importComponentInstance();
|
||||
} else if (this.manifest.pageModule) {
|
||||
return this.manifest.pageModule;
|
||||
}
|
||||
throw new Error(
|
||||
"Astro couldn't find the correct page to render, probably because it wasn't correctly mapped for SSR usage. This is an internal error, please file an issue.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ZodType } from 'zod';
|
||||
import type { ActionAccept, ActionClient } from '../../actions/runtime/server.js';
|
||||
import type { RoutingStrategies } from '../../i18n/utils.js';
|
||||
import type { ComponentInstance, SerializedRouteData } from '../../types/astro.js';
|
||||
import type { AstroMiddlewareInstance } from '../../types/public/common.js';
|
||||
import type {
|
||||
@@ -18,6 +17,9 @@ import type {
|
||||
} from '../../types/public/internal.js';
|
||||
import type { SinglePageBuiltModule } from '../build/types.js';
|
||||
import type { CspDirective } from '../csp/config.js';
|
||||
import type { LoggerLevel } from '../logger/core.js';
|
||||
import type { SessionDriver } from '../session.js';
|
||||
import type { RoutingStrategies } from './common.js';
|
||||
|
||||
type ComponentPath = string;
|
||||
|
||||
@@ -25,16 +27,16 @@ export type StylesheetAsset =
|
||||
| { type: 'inline'; content: string }
|
||||
| { type: 'external'; src: string };
|
||||
|
||||
type ScriptAsset =
|
||||
| { children: string; stage: string }
|
||||
// Hoisted
|
||||
| { type: 'inline' | 'external'; value: string };
|
||||
|
||||
export interface RouteInfo {
|
||||
routeData: RouteData;
|
||||
file: string;
|
||||
links: string[];
|
||||
scripts: // Integration injected
|
||||
(
|
||||
| { children: string; stage: string }
|
||||
// Hoisted
|
||||
| { type: 'inline' | 'external'; value: string }
|
||||
)[];
|
||||
scripts: ScriptAsset[];
|
||||
styles: StylesheetAsset[];
|
||||
}
|
||||
|
||||
@@ -44,6 +46,11 @@ export type SerializedRouteInfo = Omit<RouteInfo, 'routeData'> & {
|
||||
|
||||
type ImportComponentInstance = () => Promise<SinglePageBuiltModule>;
|
||||
|
||||
export type ServerIslandMappings = {
|
||||
serverIslandMap?: Map<string, () => Promise<ComponentInstance>>;
|
||||
serverIslandNameMap?: Map<string, string>;
|
||||
};
|
||||
|
||||
export type AssetsPrefix =
|
||||
| string
|
||||
| ({
|
||||
@@ -52,7 +59,6 @@ export type AssetsPrefix =
|
||||
| undefined;
|
||||
|
||||
export type SSRManifest = {
|
||||
hrefRoot: string;
|
||||
adapterName: string;
|
||||
routes: RouteInfo[];
|
||||
site?: string;
|
||||
@@ -69,6 +75,13 @@ export type SSRManifest = {
|
||||
compressHTML: boolean;
|
||||
assetsPrefix?: AssetsPrefix;
|
||||
renderers: SSRLoadedRenderer[];
|
||||
/**
|
||||
* Based on Astro config's `output` option, `true` if "server" or "hybrid".
|
||||
*
|
||||
* Whether this application is SSR-like. If so, this has some implications, such as
|
||||
* the creation of `dist/client` and `dist/server` folders.
|
||||
*/
|
||||
serverLike: boolean;
|
||||
/**
|
||||
* Map of directive name (e.g. `load`) to the directive script code
|
||||
*/
|
||||
@@ -79,23 +92,40 @@ export type SSRManifest = {
|
||||
componentMetadata: SSRResult['componentMetadata'];
|
||||
pageModule?: SinglePageBuiltModule;
|
||||
pageMap?: Map<ComponentPath, ImportComponentInstance>;
|
||||
serverIslandMap?: Map<string, () => Promise<ComponentInstance>>;
|
||||
serverIslandNameMap?: Map<string, string>;
|
||||
serverIslandMappings?: () => Promise<ServerIslandMappings> | ServerIslandMappings;
|
||||
key: Promise<CryptoKey>;
|
||||
i18n: SSRManifestI18n | undefined;
|
||||
middleware?: () => Promise<AstroMiddlewareInstance> | AstroMiddlewareInstance;
|
||||
actions?: () => Promise<SSRActions> | SSRActions;
|
||||
sessionDriver?: () => Promise<{ default: SessionDriver | null }>;
|
||||
checkOrigin: boolean;
|
||||
allowedDomains?: Partial<RemotePattern>[];
|
||||
sessionConfig?: ResolvedSessionConfig<any>;
|
||||
cacheDir: string | URL;
|
||||
srcDir: string | URL;
|
||||
outDir: string | URL;
|
||||
publicDir: string | URL;
|
||||
buildClientDir: string | URL;
|
||||
buildServerDir: string | URL;
|
||||
cacheDir: URL;
|
||||
srcDir: URL;
|
||||
outDir: URL;
|
||||
rootDir: URL;
|
||||
publicDir: URL;
|
||||
assetsDir: string;
|
||||
buildClientDir: URL;
|
||||
buildServerDir: URL;
|
||||
csp: SSRManifestCSP | undefined;
|
||||
devToolbar: {
|
||||
// This should always be false in prod/SSR
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Latest version of Astro, will be undefined if:
|
||||
* - unable to check
|
||||
* - the user has disabled the check
|
||||
* - the check has not completed yet
|
||||
* - the user is on the latest version already
|
||||
*/
|
||||
latestAstroVersion: string | undefined;
|
||||
|
||||
debugInfoOutput: string | undefined;
|
||||
};
|
||||
internalFetchHeaders?: Record<string, string>;
|
||||
logLevel: LoggerLevel;
|
||||
};
|
||||
|
||||
export type SSRActions = {
|
||||
@@ -109,6 +139,7 @@ export type SSRManifestI18n = {
|
||||
locales: Locales;
|
||||
defaultLocale: string;
|
||||
domainLookupTable: Record<string, string>;
|
||||
domains: Record<string, string> | undefined;
|
||||
};
|
||||
|
||||
export type SSRManifestCSP = {
|
||||
@@ -133,13 +164,26 @@ export type SerializedSSRManifest = Omit<
|
||||
| 'clientDirectives'
|
||||
| 'serverIslandNameMap'
|
||||
| 'key'
|
||||
| 'rootDir'
|
||||
| 'srcDir'
|
||||
| 'cacheDir'
|
||||
| 'outDir'
|
||||
| 'publicDir'
|
||||
| 'buildClientDir'
|
||||
| 'buildServerDir'
|
||||
> & {
|
||||
rootDir: string;
|
||||
srcDir: string;
|
||||
cacheDir: string;
|
||||
outDir: string;
|
||||
publicDir: string;
|
||||
buildClientDir: string;
|
||||
buildServerDir: string;
|
||||
routes: SerializedRouteInfo[];
|
||||
assets: string[];
|
||||
componentMetadata: [string, SSRComponentMetadata][];
|
||||
inlinedScripts: [string, string][];
|
||||
clientDirectives: [string, string][];
|
||||
serverIslandNameMap: [string, string][];
|
||||
key: string;
|
||||
};
|
||||
|
||||
|
||||
95
packages/astro/src/core/app/validate-forwarded-headers.ts
Normal file
95
packages/astro/src/core/app/validate-forwarded-headers.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { matchPattern, type RemotePattern } from '../../assets/utils/remotePattern.js';
|
||||
|
||||
/**
|
||||
* Validate a hostname by rejecting any with path separators.
|
||||
* Prevents path injection attacks. Invalid hostnames return undefined.
|
||||
*/
|
||||
export function sanitizeHost(hostname: string | undefined): string | undefined {
|
||||
if (!hostname) return undefined;
|
||||
// Reject any hostname containing path separators - they're invalid
|
||||
if (/[/\\]/.test(hostname)) return undefined;
|
||||
return hostname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate forwarded headers (proto, host, port) against allowedDomains.
|
||||
* Returns validated values or undefined for rejected headers.
|
||||
* Uses strict defaults: http/https only for proto, rejects port if not in allowedDomains.
|
||||
*/
|
||||
export function validateForwardedHeaders(
|
||||
forwardedProtocol?: string,
|
||||
forwardedHost?: string,
|
||||
forwardedPort?: string,
|
||||
allowedDomains?: Partial<RemotePattern>[],
|
||||
): { protocol?: string; host?: string; port?: string } {
|
||||
const result: { protocol?: string; host?: string; port?: string } = {};
|
||||
|
||||
// Validate protocol
|
||||
if (forwardedProtocol) {
|
||||
if (allowedDomains && allowedDomains.length > 0) {
|
||||
const hasProtocolPatterns = allowedDomains.some(
|
||||
(pattern) => pattern.protocol !== undefined,
|
||||
);
|
||||
if (hasProtocolPatterns) {
|
||||
// Validate against allowedDomains patterns
|
||||
try {
|
||||
const testUrl = new URL(`${forwardedProtocol}://example.com`);
|
||||
const isAllowed = allowedDomains.some((pattern) => matchPattern(testUrl, pattern));
|
||||
if (isAllowed) {
|
||||
result.protocol = forwardedProtocol;
|
||||
}
|
||||
} catch {
|
||||
// Invalid protocol, omit from result
|
||||
}
|
||||
} else if (/^https?$/.test(forwardedProtocol)) {
|
||||
// allowedDomains exist but no protocol patterns, allow http/https
|
||||
result.protocol = forwardedProtocol;
|
||||
}
|
||||
} else if (/^https?$/.test(forwardedProtocol)) {
|
||||
// No allowedDomains, only allow http/https
|
||||
result.protocol = forwardedProtocol;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate port first
|
||||
if (forwardedPort && allowedDomains && allowedDomains.length > 0) {
|
||||
const hasPortPatterns = allowedDomains.some((pattern) => pattern.port !== undefined);
|
||||
if (hasPortPatterns) {
|
||||
// Validate against allowedDomains patterns
|
||||
const isAllowed = allowedDomains.some((pattern) => pattern.port === forwardedPort);
|
||||
if (isAllowed) {
|
||||
result.port = forwardedPort;
|
||||
}
|
||||
}
|
||||
// If no port patterns, reject the header (strict security default)
|
||||
}
|
||||
|
||||
// Validate host (extract port from hostname for validation)
|
||||
// Reject empty strings and sanitize to prevent path injection
|
||||
if (forwardedHost && forwardedHost.length > 0 && allowedDomains && allowedDomains.length > 0) {
|
||||
const protoForValidation = result.protocol || 'https';
|
||||
const sanitized = sanitizeHost(forwardedHost);
|
||||
if (sanitized) {
|
||||
try {
|
||||
// Extract hostname without port for validation
|
||||
const hostnameOnly = sanitized.split(':')[0];
|
||||
// Use full hostname:port for validation so patterns with ports match correctly
|
||||
// Include validated port if available, otherwise use port from forwardedHost if present
|
||||
const portFromHost = sanitized.includes(':') ? sanitized.split(':')[1] : undefined;
|
||||
const portForValidation = result.port || portFromHost;
|
||||
const hostWithPort = portForValidation
|
||||
? `${hostnameOnly}:${portForValidation}`
|
||||
: hostnameOnly;
|
||||
const testUrl = new URL(`${protoForValidation}://${hostWithPort}`);
|
||||
const isAllowed = allowedDomains.some((pattern) => matchPattern(testUrl, pattern));
|
||||
if (isAllowed) {
|
||||
result.host = sanitized;
|
||||
}
|
||||
} catch {
|
||||
// Invalid host, omit from result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -13,13 +13,17 @@ import type {
|
||||
SSRResult,
|
||||
} from '../types/public/internal.js';
|
||||
import { createOriginCheckMiddleware } from './app/middlewares.js';
|
||||
import type { ServerIslandMappings } from './app/types.js';
|
||||
import type { SinglePageBuiltModule } from './build/types.js';
|
||||
import { ActionNotFoundError } from './errors/errors-data.js';
|
||||
import { AstroError } from './errors/index.js';
|
||||
import type { Logger } from './logger/core.js';
|
||||
import { NOOP_MIDDLEWARE_FN } from './middleware/noop-middleware.js';
|
||||
import { sequence } from './middleware/sequence.js';
|
||||
import { RedirectSinglePageBuiltModule } from './redirects/index.js';
|
||||
import { RouteCache } from './render/route-cache.js';
|
||||
import { createDefaultRoutes } from './routing/default.js';
|
||||
import type { SessionDriver } from './session.js';
|
||||
|
||||
/**
|
||||
* The `Pipeline` represents the static parts of rendering that do not change between requests.
|
||||
@@ -31,6 +35,7 @@ export abstract class Pipeline {
|
||||
readonly internalMiddleware: MiddlewareHandler[];
|
||||
resolvedMiddleware: MiddlewareHandler | undefined = undefined;
|
||||
resolvedActions: SSRActions | undefined = undefined;
|
||||
resolvedSessionDriver: SessionDriver | null | undefined = undefined;
|
||||
|
||||
constructor(
|
||||
readonly logger: Logger,
|
||||
@@ -41,10 +46,7 @@ export abstract class Pipeline {
|
||||
readonly runtimeMode: RuntimeMode,
|
||||
readonly renderers: SSRLoadedRenderer[],
|
||||
readonly resolve: (s: string) => Promise<string>,
|
||||
/**
|
||||
* Based on Astro config's `output` option, `true` if "server" or "hybrid".
|
||||
*/
|
||||
readonly serverLike: boolean,
|
||||
|
||||
readonly streaming: boolean,
|
||||
/**
|
||||
* Used to provide better error messages for `Astro.clientAddress`
|
||||
@@ -67,6 +69,8 @@ export abstract class Pipeline {
|
||||
readonly defaultRoutes = createDefaultRoutes(manifest),
|
||||
|
||||
readonly actions = manifest.actions,
|
||||
readonly sessionDriver = manifest.sessionDriver,
|
||||
readonly serverIslands = manifest.serverIslandMappings,
|
||||
) {
|
||||
this.internalMiddleware = [];
|
||||
// We do use our middleware only if the user isn't using the manual setup
|
||||
@@ -99,6 +103,11 @@ export abstract class Pipeline {
|
||||
*/
|
||||
abstract getComponentByRoute(routeData: RouteData): Promise<ComponentInstance>;
|
||||
|
||||
/**
|
||||
* The current name of the pipeline. Useful for debugging
|
||||
*/
|
||||
abstract getName(): string;
|
||||
|
||||
/**
|
||||
* Resolves the middleware from the manifest, and returns the `onRequest` function. If `onRequest` isn't there,
|
||||
* it returns a no-op function
|
||||
@@ -125,19 +134,44 @@ export abstract class Pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
setActions(actions: SSRActions) {
|
||||
this.resolvedActions = actions;
|
||||
}
|
||||
|
||||
async getActions(): Promise<SSRActions> {
|
||||
if (this.resolvedActions) {
|
||||
return this.resolvedActions;
|
||||
} else if (this.actions) {
|
||||
return await this.actions();
|
||||
return this.actions();
|
||||
}
|
||||
return NOOP_ACTIONS_MOD;
|
||||
}
|
||||
|
||||
async getSessionDriver(): Promise<SessionDriver | null> {
|
||||
// Return cached value if already resolved (including null)
|
||||
if (this.resolvedSessionDriver !== undefined) {
|
||||
return this.resolvedSessionDriver;
|
||||
}
|
||||
|
||||
// Try to load the driver from the manifest
|
||||
if (this.sessionDriver) {
|
||||
const driverModule = await this.sessionDriver();
|
||||
this.resolvedSessionDriver = driverModule?.default || null;
|
||||
return this.resolvedSessionDriver;
|
||||
}
|
||||
|
||||
// No driver configured
|
||||
this.resolvedSessionDriver = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
async getServerIslands(): Promise<ServerIslandMappings> {
|
||||
if (this.serverIslands) {
|
||||
return this.serverIslands();
|
||||
}
|
||||
|
||||
return {
|
||||
serverIslandMap: new Map(),
|
||||
serverIslandNameMap: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
async getAction(path: string): Promise<ActionClient<unknown, ActionAccept, ZodType>> {
|
||||
const pathKeys = path.split('.').map((key) => decodeURIComponent(key));
|
||||
let { server } = await this.getActions();
|
||||
@@ -165,6 +199,35 @@ export abstract class Pipeline {
|
||||
}
|
||||
return server;
|
||||
}
|
||||
|
||||
async getModuleForRoute(route: RouteData): Promise<SinglePageBuiltModule> {
|
||||
for (const defaultRoute of this.defaultRoutes) {
|
||||
if (route.component === defaultRoute.component) {
|
||||
return {
|
||||
page: () => Promise.resolve(defaultRoute.instance),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (route.type === 'redirect') {
|
||||
return RedirectSinglePageBuiltModule;
|
||||
} else {
|
||||
if (this.manifest.pageMap) {
|
||||
const importComponentInstance = this.manifest.pageMap.get(route.component);
|
||||
if (!importComponentInstance) {
|
||||
throw new Error(
|
||||
`Unexpectedly unable to find a component instance for route ${route.route}`,
|
||||
);
|
||||
}
|
||||
return await importComponentInstance();
|
||||
} else if (this.manifest.pageModule) {
|
||||
return this.manifest.pageModule;
|
||||
}
|
||||
throw new Error(
|
||||
"Astro couldn't find the correct page to render, probably because it wasn't correctly mapped for SSR usage. This is an internal error, please file an issue.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
|
||||
44
packages/astro/src/core/build/app.ts
Normal file
44
packages/astro/src/core/build/app.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { BaseApp, type RenderErrorOptions } from '../app/index.js';
|
||||
import type { SSRManifest } from '../app/types.js';
|
||||
import type { BuildInternals } from './internal.js';
|
||||
import { BuildPipeline } from './pipeline.js';
|
||||
import type { StaticBuildOptions } from './types.js';
|
||||
|
||||
export class BuildApp extends BaseApp<BuildPipeline> {
|
||||
createPipeline(_streaming: boolean, manifest: SSRManifest, ..._args: any[]): BuildPipeline {
|
||||
return BuildPipeline.create({
|
||||
manifest,
|
||||
});
|
||||
}
|
||||
|
||||
public setInternals(internals: BuildInternals) {
|
||||
this.pipeline.setInternals(internals);
|
||||
}
|
||||
|
||||
public setOptions(options: StaticBuildOptions) {
|
||||
this.pipeline.setOptions(options);
|
||||
this.logger = options.logger;
|
||||
}
|
||||
|
||||
public getOptions() {
|
||||
return this.pipeline.getOptions();
|
||||
}
|
||||
|
||||
public getSettings() {
|
||||
return this.pipeline.getSettings();
|
||||
}
|
||||
|
||||
async renderError(request: Request, options: RenderErrorOptions): Promise<Response> {
|
||||
if (options.status === 500) {
|
||||
if(options.response) {
|
||||
return options.response;
|
||||
}
|
||||
throw options.error;
|
||||
} else {
|
||||
return super.renderError(request, {
|
||||
...options,
|
||||
prerenderedErrorPageFetch: undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export function getOutFolder(
|
||||
}
|
||||
|
||||
export function getOutFile(
|
||||
astroConfig: AstroConfig,
|
||||
buildFormat: NonNullable<AstroConfig['build']>['format'],
|
||||
outFolder: URL,
|
||||
pathname: string,
|
||||
routeData: RouteData,
|
||||
@@ -70,7 +70,7 @@ export function getOutFile(
|
||||
case 'page':
|
||||
case 'fallback':
|
||||
case 'redirect':
|
||||
switch (astroConfig.build.format) {
|
||||
switch (buildFormat) {
|
||||
case 'directory': {
|
||||
if (STATUS_CODE_PAGES.has(pathname)) {
|
||||
const baseName = npath.basename(pathname);
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import crypto from 'node:crypto';
|
||||
import npath from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { GetModuleInfo, ModuleInfo } from 'rollup';
|
||||
import type { AstroSettings } from '../../types/astro.js';
|
||||
import { viteID } from '../util.js';
|
||||
import { normalizePath } from '../viteUtils.js';
|
||||
import { getTopLevelPageModuleInfos } from './graph.js';
|
||||
|
||||
// These pages could be used as base names for the chunk hashed name, but they are confusing
|
||||
// and should be avoided it possible
|
||||
const confusingBaseNames = ['404', '500'];
|
||||
|
||||
// The short name for when the hash can be included
|
||||
// We could get rid of this and only use the createSlugger implementation, but this creates
|
||||
// slightly prettier names.
|
||||
export function shortHashedName(settings: AstroSettings) {
|
||||
return function (id: string, ctx: { getModuleInfo: GetModuleInfo }): string {
|
||||
const parents = getTopLevelPageModuleInfos(id, ctx);
|
||||
return createNameHash(
|
||||
getFirstParentId(parents),
|
||||
parents.map((page) => page.id),
|
||||
settings,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function createNameHash(
|
||||
baseId: string | undefined,
|
||||
hashIds: string[],
|
||||
settings: AstroSettings,
|
||||
): string {
|
||||
const baseName = baseId ? prettifyBaseName(npath.parse(baseId).name) : 'index';
|
||||
const hash = crypto.createHash('sha256');
|
||||
const root = fileURLToPath(settings.config.root);
|
||||
|
||||
for (const id of hashIds) {
|
||||
// Strip the project directory from the paths before they are hashed, so that assets
|
||||
// that import these css files have consistent hashes when built in different environments.
|
||||
const relativePath = npath.relative(root, id);
|
||||
// Normalize the path to fix differences between windows and other environments
|
||||
hash.update(normalizePath(relativePath), 'utf-8');
|
||||
}
|
||||
const h = hash.digest('hex').slice(0, 8);
|
||||
const proposedName = baseName + '.' + h;
|
||||
return proposedName;
|
||||
}
|
||||
|
||||
export function createSlugger(settings: AstroSettings) {
|
||||
const pagesDir = viteID(new URL('./pages', settings.config.srcDir));
|
||||
const indexPage = viteID(new URL('./pages/index', settings.config.srcDir));
|
||||
const map = new Map<string, Map<string, number>>();
|
||||
const sep = '-';
|
||||
return function (id: string, ctx: { getModuleInfo: GetModuleInfo }): string {
|
||||
const parents = Array.from(getTopLevelPageModuleInfos(id, ctx));
|
||||
const allParentsKey = parents
|
||||
.map((page) => page.id)
|
||||
.sort()
|
||||
.join('-');
|
||||
const firstParentId = getFirstParentId(parents) || indexPage;
|
||||
|
||||
// Use the last two segments, for ex /docs/index
|
||||
let dir = firstParentId;
|
||||
let key = '';
|
||||
let i = 0;
|
||||
while (i < 2) {
|
||||
if (dir === pagesDir) {
|
||||
break;
|
||||
}
|
||||
|
||||
const name = prettifyBaseName(npath.parse(npath.basename(dir)).name);
|
||||
key = key.length ? name + sep + key : name;
|
||||
dir = npath.dirname(dir);
|
||||
i++;
|
||||
}
|
||||
|
||||
// Keep track of how many times this was used.
|
||||
let name = key;
|
||||
|
||||
// The map keeps track of how many times a key, like `pages_index` is used as the name.
|
||||
// If the same key is used more than once we increment a number so it becomes `pages-index-1`.
|
||||
// This guarantees that it stays unique, without sacrificing pretty names.
|
||||
if (!map.has(key)) {
|
||||
map.set(key, new Map([[allParentsKey, 0]]));
|
||||
} else {
|
||||
const inner = map.get(key)!;
|
||||
if (inner.has(allParentsKey)) {
|
||||
const num = inner.get(allParentsKey)!;
|
||||
if (num > 0) {
|
||||
name = name + sep + num;
|
||||
}
|
||||
} else {
|
||||
const num = inner.size;
|
||||
inner.set(allParentsKey, num);
|
||||
name = name + sep + num;
|
||||
}
|
||||
}
|
||||
|
||||
return name;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first parent id from `parents` where its name is not confusing.
|
||||
* Returns undefined if there's no parents.
|
||||
*/
|
||||
function getFirstParentId(parents: ModuleInfo[]) {
|
||||
for (const parent of parents) {
|
||||
const id = parent.id;
|
||||
const baseName = npath.parse(id).name;
|
||||
if (!confusingBaseNames.includes(baseName)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
// If all parents are confusing, just use the first one. Or if there's no
|
||||
// parents, this will return undefined.
|
||||
return parents[0]?.id;
|
||||
}
|
||||
|
||||
const charsToReplaceRe = /[.[\]]/g;
|
||||
const underscoresRe = /_+/g;
|
||||
/**
|
||||
* Prettify base names so they're easier to read:
|
||||
* - index -> index
|
||||
* - [slug] -> _slug_
|
||||
* - [...spread] -> _spread_
|
||||
*/
|
||||
function prettifyBaseName(str: string) {
|
||||
return str.replace(charsToReplaceRe, '_').replace(underscoresRe, '_');
|
||||
}
|
||||
@@ -3,97 +3,63 @@ import os from 'node:os';
|
||||
import PLimit from 'p-limit';
|
||||
import PQueue from 'p-queue';
|
||||
import colors from 'piccolore';
|
||||
import { NOOP_ACTIONS_MOD } from '../../actions/noop-actions.js';
|
||||
import {
|
||||
generateImagesForPath,
|
||||
getStaticImageList,
|
||||
prepareAssetsGenerationEnv,
|
||||
} from '../../assets/build/generate.js';
|
||||
import {
|
||||
collapseDuplicateTrailingSlashes,
|
||||
isRelativePath,
|
||||
joinPaths,
|
||||
removeLeadingForwardSlash,
|
||||
removeTrailingForwardSlash,
|
||||
trimSlashes,
|
||||
} from '../../core/path.js';
|
||||
import { toFallbackType, toRoutingStrategy } from '../../i18n/utils.js';
|
||||
import { runHookBuildGenerated, toIntegrationResolvedRoute } from '../../integrations/hooks.js';
|
||||
import { getServerOutputDirectory } from '../../prerender/utils.js';
|
||||
import type { AstroSettings, ComponentInstance } from '../../types/astro.js';
|
||||
import type { GetStaticPathsItem, MiddlewareHandler } from '../../types/public/common.js';
|
||||
import type { GetStaticPathsItem } from '../../types/public/common.js';
|
||||
import type { AstroConfig } from '../../types/public/config.js';
|
||||
import type { IntegrationResolvedRoute, RouteToHeaders } from '../../types/public/index.js';
|
||||
import type {
|
||||
RouteData,
|
||||
RouteType,
|
||||
SSRError,
|
||||
SSRLoadedRenderer,
|
||||
} from '../../types/public/internal.js';
|
||||
import type { SSRActions, SSRManifest, SSRManifestCSP, SSRManifestI18n } from '../app/types.js';
|
||||
import {
|
||||
getAlgorithm,
|
||||
getDirectives,
|
||||
getScriptHashes,
|
||||
getScriptResources,
|
||||
getStrictDynamic,
|
||||
getStyleHashes,
|
||||
getStyleResources,
|
||||
shouldTrackCspHashes,
|
||||
trackScriptHashes,
|
||||
trackStyleHashes,
|
||||
} from '../csp/common.js';
|
||||
import type { RouteData, RouteType, SSRError } from '../../types/public/internal.js';
|
||||
import { NoPrerenderedRoutesWithDomains } from '../errors/errors-data.js';
|
||||
import { AstroError, AstroErrorData } from '../errors/index.js';
|
||||
import { NOOP_MIDDLEWARE_FN } from '../middleware/noop-middleware.js';
|
||||
import { getRedirectLocationOrThrow, routeIsRedirect } from '../redirects/index.js';
|
||||
import { getRedirectLocationOrThrow } from '../redirects/index.js';
|
||||
import { callGetStaticPaths } from '../render/route-cache.js';
|
||||
import { RenderContext } from '../render-context.js';
|
||||
import { createRequest } from '../request.js';
|
||||
import { redirectTemplate } from '../routing/3xx.js';
|
||||
import { getFallbackRoute, routeIsFallback, routeIsRedirect } from '../routing/helpers.js';
|
||||
import { matchRoute } from '../routing/match.js';
|
||||
import { stringifyParams } from '../routing/params.js';
|
||||
import { getOutputFilename } from '../util.js';
|
||||
import type { BuildApp } from './app.js';
|
||||
import { getOutFile, getOutFolder } from './common.js';
|
||||
import { type BuildInternals, cssOrder, hasPrerenderedPages, mergeInlineCss } from './internal.js';
|
||||
import { BuildPipeline } from './pipeline.js';
|
||||
import type {
|
||||
PageBuildData,
|
||||
SinglePageBuiltModule,
|
||||
StaticBuildOptions,
|
||||
StylesheetAsset,
|
||||
} from './types.js';
|
||||
import { type BuildInternals, hasPrerenderedPages } from './internal.js';
|
||||
import type { StaticBuildOptions } from './types.js';
|
||||
import { getTimeStat, shouldAppendForwardSlash } from './util.js';
|
||||
|
||||
const { bgGreen, black, blue, bold, dim, green, magenta, red, yellow } = colors;
|
||||
|
||||
export async function generatePages(options: StaticBuildOptions, internals: BuildInternals) {
|
||||
export async function generatePages(
|
||||
options: StaticBuildOptions,
|
||||
internals: BuildInternals,
|
||||
prerenderOutputDir: URL,
|
||||
) {
|
||||
const generatePagesTimer = performance.now();
|
||||
const ssr = options.settings.buildOutput === 'server';
|
||||
let manifest: SSRManifest;
|
||||
if (ssr) {
|
||||
manifest = await BuildPipeline.retrieveManifest(options.settings, internals);
|
||||
} else {
|
||||
const baseDirectory = getServerOutputDirectory(options.settings);
|
||||
const renderersEntryUrl = new URL('renderers.mjs', baseDirectory);
|
||||
const renderers = await import(renderersEntryUrl.toString());
|
||||
const middleware: MiddlewareHandler = internals.middlewareEntryPoint
|
||||
? await import(internals.middlewareEntryPoint.toString()).then((mod) => mod.onRequest)
|
||||
: NOOP_MIDDLEWARE_FN;
|
||||
|
||||
const actions: SSRActions = internals.astroActionsEntryPoint
|
||||
? await import(internals.astroActionsEntryPoint.toString()).then((mod) => mod)
|
||||
: NOOP_ACTIONS_MOD;
|
||||
manifest = await createBuildManifest(
|
||||
options.settings,
|
||||
internals,
|
||||
renderers.renderers as SSRLoadedRenderer[],
|
||||
middleware,
|
||||
actions,
|
||||
options.key,
|
||||
// Import from the single prerender entrypoint
|
||||
const prerenderEntryFileName = internals.prerenderEntryFileName;
|
||||
if (!prerenderEntryFileName) {
|
||||
throw new Error(
|
||||
`Prerender entry filename not found in build internals. This is likely a bug in Astro.`,
|
||||
);
|
||||
}
|
||||
const pipeline = BuildPipeline.create({ internals, manifest, options });
|
||||
const { config, logger } = pipeline;
|
||||
const prerenderEntryUrl = new URL(prerenderEntryFileName, prerenderOutputDir);
|
||||
const prerenderEntry = await import(prerenderEntryUrl.toString());
|
||||
|
||||
// Grab the manifest and create the pipeline
|
||||
const app = prerenderEntry.app as BuildApp;
|
||||
app.setInternals(internals);
|
||||
app.setOptions(options);
|
||||
|
||||
const logger = app.logger;
|
||||
|
||||
// HACK! `astro:assets` relies on a global to know if its running in dev, prod, ssr, ssg, full moon
|
||||
// If we don't delete it here, it's technically not impossible (albeit improbable) for it to leak
|
||||
@@ -102,47 +68,44 @@ export async function generatePages(options: StaticBuildOptions, internals: Buil
|
||||
}
|
||||
|
||||
const verb = ssr ? 'prerendering' : 'generating';
|
||||
logger.info('SKIP_FORMAT', `\n${bgGreen(black(` ${verb} static routes `))}`);
|
||||
logger.info('SKIP_FORMAT', `\n${colors.bgGreen(colors.black(` ${verb} static routes `))}`);
|
||||
const builtPaths = new Set<string>();
|
||||
const pagesToGenerate = pipeline.retrieveRoutesToGenerate();
|
||||
const pagesToGenerate = app.pipeline.retrieveRoutesToGenerate();
|
||||
const routeToHeaders: RouteToHeaders = new Map();
|
||||
|
||||
if (ssr) {
|
||||
for (const [pageData, filePath] of pagesToGenerate) {
|
||||
if (pageData.route.prerender) {
|
||||
for (const routeData of pagesToGenerate) {
|
||||
if (routeData.prerender) {
|
||||
// i18n domains won't work with pre rendered routes at the moment, so we need to throw an error
|
||||
if (config.i18n?.domains && Object.keys(config.i18n.domains).length > 0) {
|
||||
if (app.manifest.i18n?.domains && Object.keys(app.manifest.i18n.domains).length > 0) {
|
||||
throw new AstroError({
|
||||
...NoPrerenderedRoutesWithDomains,
|
||||
message: NoPrerenderedRoutesWithDomains.message(pageData.component),
|
||||
message: NoPrerenderedRoutesWithDomains.message(routeData.component),
|
||||
});
|
||||
}
|
||||
|
||||
const ssrEntryPage = await pipeline.retrieveSsrEntry(pageData.route, filePath);
|
||||
|
||||
const ssrEntry = ssrEntryPage as SinglePageBuiltModule;
|
||||
await generatePage(pageData, ssrEntry, builtPaths, pipeline, routeToHeaders);
|
||||
await generatePage(app, routeData, builtPaths, routeToHeaders);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const [pageData, filePath] of pagesToGenerate) {
|
||||
const entry = await pipeline.retrieveSsrEntry(pageData.route, filePath);
|
||||
await generatePage(pageData, entry, builtPaths, pipeline, routeToHeaders);
|
||||
for (const routeData of pagesToGenerate) {
|
||||
await generatePage(app, routeData, builtPaths, routeToHeaders);
|
||||
}
|
||||
}
|
||||
logger.info(
|
||||
null,
|
||||
green(`✓ Completed in ${getTimeStat(generatePagesTimer, performance.now())}.\n`),
|
||||
colors.green(`✓ Completed in ${getTimeStat(generatePagesTimer, performance.now())}.\n`),
|
||||
);
|
||||
|
||||
const staticImageList = getStaticImageList();
|
||||
if (staticImageList.size) {
|
||||
logger.info('SKIP_FORMAT', `${bgGreen(black(` generating optimized images `))}`);
|
||||
logger.info('SKIP_FORMAT', `${colors.bgGreen(colors.black(` generating optimized images `))}`);
|
||||
|
||||
const totalCount = Array.from(staticImageList.values())
|
||||
.map((x) => x.transforms.size)
|
||||
.reduce((a, b) => a + b, 0);
|
||||
const cpuCount = os.cpus().length;
|
||||
const assetsCreationPipeline = await prepareAssetsGenerationEnv(pipeline, totalCount);
|
||||
const assetsCreationPipeline = await prepareAssetsGenerationEnv(app, totalCount);
|
||||
const queue = new PQueue({ concurrency: Math.max(cpuCount, 1) });
|
||||
|
||||
const assetsTimer = performance.now();
|
||||
@@ -218,7 +181,7 @@ export async function generatePages(options: StaticBuildOptions, internals: Buil
|
||||
|
||||
await queue.onIdle();
|
||||
const assetsTimeEnd = performance.now();
|
||||
logger.info(null, green(`✓ Completed in ${getTimeStat(assetsTimer, assetsTimeEnd)}.\n`));
|
||||
logger.info(null, colors.green(`✓ Completed in ${getTimeStat(assetsTimer, assetsTimeEnd)}.\n`));
|
||||
|
||||
delete globalThis?.astroAsset?.addStaticImage;
|
||||
}
|
||||
@@ -233,36 +196,14 @@ export async function generatePages(options: StaticBuildOptions, internals: Buil
|
||||
const THRESHOLD_SLOW_RENDER_TIME_MS = 500;
|
||||
|
||||
async function generatePage(
|
||||
pageData: PageBuildData,
|
||||
ssrEntry: SinglePageBuiltModule,
|
||||
app: BuildApp,
|
||||
routeData: RouteData,
|
||||
builtPaths: Set<string>,
|
||||
pipeline: BuildPipeline,
|
||||
routeToHeaders: RouteToHeaders,
|
||||
) {
|
||||
// prepare information we need
|
||||
const { config, logger } = pipeline;
|
||||
const pageModulePromise = ssrEntry.page;
|
||||
|
||||
// Calculate information of the page, like scripts, links and styles
|
||||
const styles = pageData.styles
|
||||
.sort(cssOrder)
|
||||
.map(({ sheet }) => sheet)
|
||||
.reduce(mergeInlineCss, []);
|
||||
// may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc.
|
||||
const linkIds: [] = [];
|
||||
if (!pageModulePromise) {
|
||||
throw new Error(
|
||||
`Unable to find the module for ${pageData.component}. This is unexpected and likely a bug in Astro, please report.`,
|
||||
);
|
||||
}
|
||||
const pageModule = await pageModulePromise();
|
||||
const generationOptions: Readonly<GeneratePathOptions> = {
|
||||
pageData,
|
||||
linkIds,
|
||||
scripts: null,
|
||||
styles,
|
||||
mod: pageModule,
|
||||
};
|
||||
const logger = app.logger;
|
||||
const { config } = app.getSettings();
|
||||
|
||||
async function generatePathWithLogs(
|
||||
path: string,
|
||||
@@ -273,51 +214,49 @@ async function generatePage(
|
||||
isConcurrent: boolean,
|
||||
) {
|
||||
const timeStart = performance.now();
|
||||
pipeline.logger.debug('build', `Generating: ${path}`);
|
||||
logger.debug('build', `Generating: ${path}`);
|
||||
|
||||
const filePath = getOutputFilename(config, path, pageData.route);
|
||||
const filePath = getOutputFilename(app.manifest.buildFormat, path, routeData);
|
||||
const lineIcon =
|
||||
(index === paths.length - 1 && !isConcurrent) || paths.length === 1 ? '└─' : '├─';
|
||||
|
||||
// Log the rendering path first if not concurrent. We'll later append the time taken to render.
|
||||
// We skip if it's concurrent as the logs may overlap
|
||||
if (!isConcurrent) {
|
||||
logger.info(null, ` ${blue(lineIcon)} ${dim(filePath)}`, false);
|
||||
logger.info(null, ` ${colors.blue(lineIcon)} ${colors.dim(filePath)}`, false);
|
||||
}
|
||||
|
||||
const created = await generatePath(
|
||||
path,
|
||||
pipeline,
|
||||
generationOptions,
|
||||
route,
|
||||
integrationRoute,
|
||||
routeToHeaders,
|
||||
);
|
||||
const created = await generatePath(app, path, route, integrationRoute, routeToHeaders);
|
||||
|
||||
const timeEnd = performance.now();
|
||||
const isSlow = timeEnd - timeStart > THRESHOLD_SLOW_RENDER_TIME_MS;
|
||||
const timeIncrease = (isSlow ? red : dim)(`(+${getTimeStat(timeStart, timeEnd)})`);
|
||||
const timeIncrease = (isSlow ? colors.red : colors.dim)(
|
||||
`(+${getTimeStat(timeStart, timeEnd)})`,
|
||||
);
|
||||
const notCreated =
|
||||
created === false ? yellow('(file not created, response body was empty)') : '';
|
||||
created === false ? colors.yellow('(file not created, response body was empty)') : '';
|
||||
|
||||
if (isConcurrent) {
|
||||
logger.info(null, ` ${blue(lineIcon)} ${dim(filePath)} ${timeIncrease} ${notCreated}`);
|
||||
logger.info(
|
||||
null,
|
||||
` ${colors.blue(lineIcon)} ${colors.dim(filePath)} ${timeIncrease} ${notCreated}`,
|
||||
);
|
||||
} else {
|
||||
logger.info('SKIP_FORMAT', ` ${timeIncrease} ${notCreated}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Now we explode the routes. A route render itself, and it can render its fallbacks (i18n routing)
|
||||
for (const route of eachRouteInRouteData(pageData)) {
|
||||
const integrationRoute = toIntegrationResolvedRoute(route);
|
||||
for (const route of eachRouteInRouteData(routeData)) {
|
||||
const integrationRoute = toIntegrationResolvedRoute(route, app.manifest.trailingSlash);
|
||||
const icon =
|
||||
route.type === 'page' || route.type === 'redirect' || route.type === 'fallback'
|
||||
? green('▶')
|
||||
: magenta('λ');
|
||||
? colors.green('▶')
|
||||
: colors.magenta('λ');
|
||||
logger.info(null, `${icon} ${getPrettyRouteName(route)}`);
|
||||
|
||||
// Get paths for the route, calling getStaticPaths if needed.
|
||||
const paths = await getPathsForRoute(route, pageModule, pipeline, builtPaths);
|
||||
const paths = await getPathsForRoute(route, app, builtPaths);
|
||||
|
||||
// Generate each paths
|
||||
if (config.build.concurrency > 1) {
|
||||
@@ -339,31 +278,50 @@ async function generatePage(
|
||||
}
|
||||
}
|
||||
|
||||
function* eachRouteInRouteData(data: PageBuildData) {
|
||||
yield data.route;
|
||||
for (const fallbackRoute of data.route.fallbackRoutes) {
|
||||
function* eachRouteInRouteData(route: RouteData) {
|
||||
yield route;
|
||||
for (const fallbackRoute of route.fallbackRoutes) {
|
||||
yield fallbackRoute;
|
||||
}
|
||||
}
|
||||
|
||||
async function getPathsForRoute(
|
||||
route: RouteData,
|
||||
mod: ComponentInstance,
|
||||
pipeline: BuildPipeline,
|
||||
app: BuildApp,
|
||||
builtPaths: Set<string>,
|
||||
): Promise<Array<string>> {
|
||||
const { logger, options, routeCache, serverLike, config } = pipeline;
|
||||
const logger = app.logger;
|
||||
// which contains routeCache and other pipeline data. Eventually all pipeline info
|
||||
// should come from app.pipeline and BuildPipeline can be eliminated.
|
||||
const { routeCache } = app.pipeline;
|
||||
const manifest = app.getManifest();
|
||||
let paths: Array<string> = [];
|
||||
if (route.pathname) {
|
||||
paths.push(route.pathname);
|
||||
builtPaths.add(removeTrailingForwardSlash(route.pathname));
|
||||
} else {
|
||||
// Load page module only when we need it for getStaticPaths
|
||||
const pageModule = await app.pipeline.getComponentByRoute(route);
|
||||
|
||||
if (!pageModule) {
|
||||
throw new Error(
|
||||
`Unable to find module for ${route.component}. This is unexpected and likely a bug in Astro, please report.`,
|
||||
);
|
||||
}
|
||||
|
||||
const routeToProcess = routeIsRedirect(route)
|
||||
? route.redirectRoute
|
||||
: routeIsFallback(route)
|
||||
? getFallbackRoute(route, manifest.routes)
|
||||
: route;
|
||||
|
||||
const staticPaths = await callGetStaticPaths({
|
||||
mod,
|
||||
route,
|
||||
mod: pageModule,
|
||||
route: routeToProcess ?? route,
|
||||
routeCache,
|
||||
ssr: serverLike,
|
||||
base: config.base,
|
||||
ssr: manifest.serverLike,
|
||||
base: manifest.base,
|
||||
trailingSlash: manifest.trailingSlash,
|
||||
}).catch((err) => {
|
||||
logger.error('build', `Failed to call getStaticPaths for ${route.component}`);
|
||||
throw err;
|
||||
@@ -372,13 +330,13 @@ async function getPathsForRoute(
|
||||
const label = staticPaths.length === 1 ? 'page' : 'pages';
|
||||
logger.debug(
|
||||
'build',
|
||||
`├── ${bold(green('√'))} ${route.component} → ${magenta(`[${staticPaths.length} ${label}]`)}`,
|
||||
`├── ${colors.bold(colors.green('√'))} ${route.component} → ${colors.magenta(`[${staticPaths.length} ${label}]`)}`,
|
||||
);
|
||||
|
||||
paths = staticPaths
|
||||
.map((staticPath) => {
|
||||
try {
|
||||
return stringifyParams(staticPath.params, route);
|
||||
return stringifyParams(staticPath.params, route, app.manifest.trailingSlash);
|
||||
} catch (e) {
|
||||
if (e instanceof TypeError) {
|
||||
throw getInvalidRouteSegmentError(e, route, staticPath);
|
||||
@@ -398,7 +356,7 @@ async function getPathsForRoute(
|
||||
// NOTE: The same URL may match multiple routes in the manifest.
|
||||
// Routing priority needs to be verified here for any duplicate
|
||||
// paths to ensure routing priority rules are enforced in the final build.
|
||||
const matchedRoute = matchRoute(decodeURI(staticPath), options.routesList);
|
||||
const matchedRoute = matchRoute(decodeURI(staticPath), app.manifestData);
|
||||
|
||||
if (!matchedRoute) {
|
||||
// No route matched this path, so we can skip it.
|
||||
@@ -410,6 +368,7 @@ async function getPathsForRoute(
|
||||
return true;
|
||||
}
|
||||
|
||||
const { config } = app.getSettings();
|
||||
// Current route is lower-priority than matchedRoute.
|
||||
// Path will be skipped due to collision.
|
||||
if (config.prerenderConflictBehavior === 'error') {
|
||||
@@ -512,7 +471,7 @@ function getUrlForPath(
|
||||
}
|
||||
let buildPathname: string;
|
||||
if (pathname === '/' || pathname === '') {
|
||||
buildPathname = base;
|
||||
buildPathname = collapseDuplicateTrailingSlashes(base + ending, trailingSlash !== 'never');
|
||||
} else if (routeType === 'endpoint') {
|
||||
const buildPathRelative = removeLeadingForwardSlash(pathname);
|
||||
buildPathname = joinPaths(base, buildPathRelative);
|
||||
@@ -524,32 +483,23 @@ function getUrlForPath(
|
||||
return new URL(buildPathname, origin);
|
||||
}
|
||||
|
||||
interface GeneratePathOptions {
|
||||
pageData: PageBuildData;
|
||||
linkIds: string[];
|
||||
scripts: { type: 'inline' | 'external'; value: string } | null;
|
||||
styles: StylesheetAsset[];
|
||||
mod: ComponentInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param pathname
|
||||
* @param pipeline
|
||||
* @param gopts
|
||||
* @param route
|
||||
* Render a single pathname for a route using app.render()
|
||||
* @param app The pre-initialized Astro App
|
||||
* @param pathname The pathname to render
|
||||
* @param route The route data
|
||||
* @return {Promise<boolean | undefined>} If `false` the file hasn't been created. If `undefined` it's expected to not be created.
|
||||
*/
|
||||
async function generatePath(
|
||||
app: BuildApp,
|
||||
pathname: string,
|
||||
pipeline: BuildPipeline,
|
||||
gopts: GeneratePathOptions,
|
||||
route: RouteData,
|
||||
integrationRoute: IntegrationResolvedRoute,
|
||||
routeToHeaders: RouteToHeaders,
|
||||
): Promise<boolean | undefined> {
|
||||
const { mod } = gopts;
|
||||
const { config, logger, options } = pipeline;
|
||||
const logger = app.logger;
|
||||
const options = app.getOptions();
|
||||
const settings = app.getSettings();
|
||||
logger.debug('build', `Generating: ${pathname}`);
|
||||
|
||||
// This adds the page name to the array so it can be shown as part of stats.
|
||||
@@ -561,17 +511,18 @@ async function generatePath(
|
||||
// with the same path
|
||||
if (route.type === 'fallback' && route.pathname !== '/') {
|
||||
if (
|
||||
Object.values(options.allPages).some((val) => {
|
||||
if (val.route.pattern.test(pathname)) {
|
||||
app.manifest.routes.some((val) => {
|
||||
const { routeData } = val;
|
||||
if (routeData.pattern.test(pathname)) {
|
||||
// Check if we've matched a dynamic route
|
||||
if (val.route.params && val.route.params.length !== 0) {
|
||||
if (routeData.params && routeData.params.length !== 0) {
|
||||
// Make sure the pathname matches an entry in distURL
|
||||
if (
|
||||
val.route.distURL &&
|
||||
!val.route.distURL.find(
|
||||
routeData.distURL &&
|
||||
!routeData.distURL.find(
|
||||
(url) =>
|
||||
url.href
|
||||
.replace(config.outDir.toString(), '')
|
||||
.replace(app.manifest.outDir.toString(), '')
|
||||
.replace(/(?:\/index\.html|\.html)$/, '') == trimSlashes(pathname),
|
||||
)
|
||||
) {
|
||||
@@ -591,10 +542,10 @@ async function generatePath(
|
||||
|
||||
const url = getUrlForPath(
|
||||
pathname,
|
||||
config.base,
|
||||
app.manifest.base,
|
||||
options.origin,
|
||||
config.build.format,
|
||||
config.trailingSlash,
|
||||
app.manifest.buildFormat,
|
||||
app.manifest.trailingSlash,
|
||||
route.type,
|
||||
);
|
||||
|
||||
@@ -605,20 +556,14 @@ async function generatePath(
|
||||
isPrerendered: true,
|
||||
routePattern: route.component,
|
||||
});
|
||||
const renderContext = await RenderContext.create({
|
||||
pipeline,
|
||||
pathname: pathname,
|
||||
request,
|
||||
routeData: route,
|
||||
clientAddress: undefined,
|
||||
});
|
||||
|
||||
let body: string | Uint8Array;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await renderContext.render(mod);
|
||||
response = await app.render(request, { routeData: route });
|
||||
} catch (err) {
|
||||
if (!AstroError.is(err) && !(err as SSRError).id && typeof err === 'object') {
|
||||
logger.error('build', `Caught error rendering ${pathname}: ${err}`);
|
||||
if (err && !AstroError.is(err) && !(err as SSRError).id && typeof err === 'object') {
|
||||
(err as SSRError).id = route.component;
|
||||
}
|
||||
throw err;
|
||||
@@ -628,11 +573,11 @@ async function generatePath(
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
// Adapters may handle redirects themselves, turning off Astro's redirect handling using `config.build.redirects` in the process.
|
||||
// In that case, we skip rendering static files for the redirect routes.
|
||||
if (routeIsRedirect(route) && !config.build.redirects) {
|
||||
if (routeIsRedirect(route) && !settings.config.build.redirects) {
|
||||
return undefined;
|
||||
}
|
||||
const locationSite = getRedirectLocationOrThrow(responseHeaders);
|
||||
const siteURL = config.site;
|
||||
const siteURL = settings.config.site;
|
||||
const location = siteURL ? new URL(locationSite, siteURL) : locationSite;
|
||||
const fromPath = new URL(request.url).pathname;
|
||||
body = redirectTemplate({
|
||||
@@ -641,7 +586,7 @@ async function generatePath(
|
||||
relativeLocation: locationSite,
|
||||
from: fromPath,
|
||||
});
|
||||
if (config.compressHTML === true) {
|
||||
if (settings.config.compressHTML === true) {
|
||||
body = body.replaceAll('\n', '');
|
||||
}
|
||||
// A dynamic redirect, set the location so that integrations know about it.
|
||||
@@ -657,8 +602,8 @@ async function generatePath(
|
||||
// We encode the path because some paths will received encoded characters, e.g. /[page] VS /%5Bpage%5D.
|
||||
// Node.js decodes the paths, so to avoid a clash between paths, do encode paths again, so we create the correct files and folders requested by the user.
|
||||
const encodedPath = encodeURI(pathname);
|
||||
const outFolder = getOutFolder(pipeline.settings, encodedPath, route);
|
||||
const outFile = getOutFile(config, outFolder, encodedPath, route);
|
||||
const outFolder = getOutFolder(settings, encodedPath, route);
|
||||
const outFile = getOutFile(app.manifest.buildFormat, outFolder, encodedPath, route);
|
||||
if (route.distURL) {
|
||||
route.distURL.push(outFile);
|
||||
} else {
|
||||
@@ -666,8 +611,8 @@ async function generatePath(
|
||||
}
|
||||
|
||||
if (
|
||||
pipeline.settings.adapter?.adapterFeatures?.experimentalStaticHeaders &&
|
||||
pipeline.settings.config.experimental?.csp
|
||||
settings.adapter?.adapterFeatures?.experimentalStaticHeaders &&
|
||||
settings.config.experimental?.csp
|
||||
) {
|
||||
routeToHeaders.set(pathname, { headers: responseHeaders, route: integrationRoute });
|
||||
}
|
||||
@@ -689,92 +634,3 @@ function getPrettyRouteName(route: RouteData): string {
|
||||
}
|
||||
return route.component;
|
||||
}
|
||||
|
||||
/**
|
||||
* It creates a `SSRManifest` from the `AstroSettings`.
|
||||
*
|
||||
* Renderers needs to be pulled out from the page module emitted during the build.
|
||||
*/
|
||||
async function createBuildManifest(
|
||||
settings: AstroSettings,
|
||||
internals: BuildInternals,
|
||||
renderers: SSRLoadedRenderer[],
|
||||
middleware: MiddlewareHandler,
|
||||
actions: SSRActions,
|
||||
key: Promise<CryptoKey>,
|
||||
): Promise<SSRManifest> {
|
||||
let i18nManifest: SSRManifestI18n | undefined = undefined;
|
||||
let csp: SSRManifestCSP | undefined = undefined;
|
||||
|
||||
if (settings.config.i18n) {
|
||||
i18nManifest = {
|
||||
fallback: settings.config.i18n.fallback,
|
||||
fallbackType: toFallbackType(settings.config.i18n.routing),
|
||||
strategy: toRoutingStrategy(settings.config.i18n.routing, settings.config.i18n.domains),
|
||||
defaultLocale: settings.config.i18n.defaultLocale,
|
||||
locales: settings.config.i18n.locales,
|
||||
domainLookupTable: {},
|
||||
};
|
||||
}
|
||||
|
||||
if (shouldTrackCspHashes(settings.config.experimental.csp)) {
|
||||
const algorithm = getAlgorithm(settings.config.experimental.csp);
|
||||
const scriptHashes = [
|
||||
...getScriptHashes(settings.config.experimental.csp),
|
||||
...(await trackScriptHashes(internals, settings, algorithm)),
|
||||
];
|
||||
const styleHashes = [
|
||||
...getStyleHashes(settings.config.experimental.csp),
|
||||
...settings.injectedCsp.styleHashes,
|
||||
...(await trackStyleHashes(internals, settings, algorithm)),
|
||||
];
|
||||
|
||||
csp = {
|
||||
cspDestination: settings.adapter?.adapterFeatures?.experimentalStaticHeaders
|
||||
? 'adapter'
|
||||
: undefined,
|
||||
styleHashes,
|
||||
styleResources: getStyleResources(settings.config.experimental.csp),
|
||||
scriptHashes,
|
||||
scriptResources: getScriptResources(settings.config.experimental.csp),
|
||||
algorithm,
|
||||
directives: getDirectives(settings),
|
||||
isStrictDynamic: getStrictDynamic(settings.config.experimental.csp),
|
||||
};
|
||||
}
|
||||
return {
|
||||
hrefRoot: settings.config.root.toString(),
|
||||
srcDir: settings.config.srcDir,
|
||||
buildClientDir: settings.config.build.client,
|
||||
buildServerDir: settings.config.build.server,
|
||||
publicDir: settings.config.publicDir,
|
||||
outDir: settings.config.outDir,
|
||||
cacheDir: settings.config.cacheDir,
|
||||
trailingSlash: settings.config.trailingSlash,
|
||||
assets: new Set(),
|
||||
entryModules: Object.fromEntries(internals.entrySpecifierToBundleMap.entries()),
|
||||
inlinedScripts: internals.inlinedScripts,
|
||||
routes: [],
|
||||
adapterName: settings.adapter?.name ?? '',
|
||||
clientDirectives: settings.clientDirectives,
|
||||
compressHTML: settings.config.compressHTML,
|
||||
renderers,
|
||||
base: settings.config.base,
|
||||
userAssetsBase: settings.config?.vite?.base,
|
||||
assetsPrefix: settings.config.build.assetsPrefix,
|
||||
site: settings.config.site,
|
||||
componentMetadata: internals.componentMetadata,
|
||||
i18n: i18nManifest,
|
||||
buildFormat: settings.config.build.format,
|
||||
middleware() {
|
||||
return {
|
||||
onRequest: middleware,
|
||||
};
|
||||
},
|
||||
actions: () => actions,
|
||||
checkOrigin:
|
||||
(settings.config.security?.checkOrigin && settings.buildOutput === 'server') ?? false,
|
||||
key,
|
||||
csp,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { GetModuleInfo, ModuleInfo } from 'rollup';
|
||||
|
||||
import { ASTRO_PAGE_RESOLVED_MODULE_ID } from './plugins/plugin-pages.js';
|
||||
import { VIRTUAL_PAGE_RESOLVED_MODULE_ID } from '../../vite-plugin-pages/const.js';
|
||||
|
||||
interface ExtendedModuleInfo {
|
||||
info: ModuleInfo;
|
||||
@@ -79,8 +79,8 @@ export function getParentModuleInfos(
|
||||
// it is imported by the top-level virtual module.
|
||||
export function moduleIsTopLevelPage(info: ModuleInfo): boolean {
|
||||
return (
|
||||
info.importers[0]?.includes(ASTRO_PAGE_RESOLVED_MODULE_ID) ||
|
||||
info.dynamicImporters[0]?.includes(ASTRO_PAGE_RESOLVED_MODULE_ID)
|
||||
info.importers[0]?.includes(VIRTUAL_PAGE_RESOLVED_MODULE_ID) ||
|
||||
info.dynamicImporters[0]?.includes(VIRTUAL_PAGE_RESOLVED_MODULE_ID)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
} from '../../integrations/hooks.js';
|
||||
import type { AstroSettings, RoutesList } from '../../types/astro.js';
|
||||
import type { AstroInlineConfig, RuntimeMode } from '../../types/public/config.js';
|
||||
import { createDevelopmentManifest } from '../../vite-plugin-astro-server/plugin.js';
|
||||
import { resolveConfig } from '../config/config.js';
|
||||
import { createNodeLogger } from '../config/logging.js';
|
||||
import { createSettings } from '../config/settings.js';
|
||||
@@ -65,7 +64,11 @@ export default async function build(
|
||||
const { userConfig, astroConfig } = await resolveConfig(inlineConfig, 'build');
|
||||
telemetry.record(eventCliSession('build', userConfig));
|
||||
|
||||
const settings = await createSettings(astroConfig, fileURLToPath(astroConfig.root));
|
||||
const settings = await createSettings(
|
||||
astroConfig,
|
||||
inlineConfig.logLevel,
|
||||
fileURLToPath(astroConfig.root),
|
||||
);
|
||||
|
||||
if (inlineConfig.force) {
|
||||
// isDev is always false, because it's interested in the build command, not the output type
|
||||
@@ -120,10 +123,6 @@ class AstroBuilder {
|
||||
command: 'build',
|
||||
logger: logger,
|
||||
});
|
||||
// NOTE: this manifest is only used by the first build pass to make the `astro:manifest` function.
|
||||
// After the first build, the BuildPipeline comes into play, and it creates the proper manifest for generating the pages.
|
||||
const manifest = createDevelopmentManifest(this.settings);
|
||||
|
||||
this.routesList = await createRoutesList({ settings: this.settings }, this.logger);
|
||||
|
||||
await runHookConfigDone({ settings: this.settings, logger: logger, command: 'build' });
|
||||
@@ -142,13 +141,12 @@ class AstroBuilder {
|
||||
},
|
||||
},
|
||||
{
|
||||
routesList: this.routesList,
|
||||
settings: this.settings,
|
||||
logger: this.logger,
|
||||
mode: this.mode,
|
||||
command: 'build',
|
||||
sync: false,
|
||||
routesList: this.routesList,
|
||||
manifest,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -158,9 +156,7 @@ class AstroBuilder {
|
||||
settings: this.settings,
|
||||
logger,
|
||||
fs,
|
||||
routesList: this.routesList,
|
||||
command: 'build',
|
||||
manifest,
|
||||
});
|
||||
|
||||
return { viteConfig };
|
||||
@@ -217,15 +213,9 @@ class AstroBuilder {
|
||||
key: keyPromise,
|
||||
};
|
||||
|
||||
const { internals, ssrOutputChunkNames } = await viteBuild(opts);
|
||||
const { internals, prerenderOutputDir } = await viteBuild(opts);
|
||||
|
||||
const hasServerIslands = this.settings.serverIslandNameMap.size > 0;
|
||||
// Error if there are server islands but no adapter provided.
|
||||
if (hasServerIslands && this.settings.buildOutput !== 'server') {
|
||||
throw new AstroError(AstroErrorData.NoAdapterInstalledServerIslands);
|
||||
}
|
||||
|
||||
await staticBuild(opts, internals, ssrOutputChunkNames);
|
||||
await staticBuild(opts, internals, prerenderOutputDir);
|
||||
|
||||
// Write any additionally generated assets to disk.
|
||||
this.timer.assetsStart = performance.now();
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { Rollup } from 'vite';
|
||||
import type { SSRResult } from '../../types/public/internal.js';
|
||||
import { prependForwardSlash, removeFileExtension } from '../path.js';
|
||||
import { viteID } from '../util.js';
|
||||
import { makePageDataKey } from './plugins/util.js';
|
||||
import type { PageBuildData, StylesheetAsset, ViteID } from './types.js';
|
||||
|
||||
export interface BuildInternals {
|
||||
@@ -84,19 +82,14 @@ export interface BuildInternals {
|
||||
// A list of all static chunks and assets that are built in the client
|
||||
clientChunksAndAssets: Set<string>;
|
||||
|
||||
// The SSR entry chunk. Kept in internals to share between ssr/client build steps
|
||||
ssrEntryChunk?: Rollup.OutputChunk;
|
||||
// The SSR manifest entry chunk.
|
||||
manifestEntryChunk?: Rollup.OutputChunk;
|
||||
// All of the input modules for the client.
|
||||
clientInput: Set<string>;
|
||||
|
||||
manifestFileName?: string;
|
||||
prerenderEntryFileName?: string;
|
||||
componentMetadata: SSRResult['componentMetadata'];
|
||||
middlewareEntryPoint: URL | undefined;
|
||||
astroActionsEntryPoint: URL | undefined;
|
||||
|
||||
/**
|
||||
* Chunks in the bundle that are only used in prerendering that we can delete later
|
||||
*/
|
||||
prerenderOnlyChunks: Rollup.OutputChunk[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,6 +98,7 @@ export interface BuildInternals {
|
||||
*/
|
||||
export function createBuildInternals(): BuildInternals {
|
||||
return {
|
||||
clientInput: new Set(),
|
||||
cssModuleToChunkIdMap: new Map(),
|
||||
inlinedScripts: new Map(),
|
||||
entrySpecifierToBundleMap: new Map<string, string>(),
|
||||
@@ -112,15 +106,12 @@ export function createBuildInternals(): BuildInternals {
|
||||
pagesByViteID: new Map(),
|
||||
pagesByClientOnly: new Map(),
|
||||
pagesByScriptId: new Map(),
|
||||
|
||||
propagatedStylesMap: new Map(),
|
||||
|
||||
discoveredHydratedComponents: new Map(),
|
||||
discoveredClientOnlyComponents: new Map(),
|
||||
discoveredScripts: new Set(),
|
||||
staticFiles: new Set(),
|
||||
componentMetadata: new Map(),
|
||||
prerenderOnlyChunks: [],
|
||||
astroActionsEntryPoint: undefined,
|
||||
middlewareEntryPoint: undefined,
|
||||
clientChunksAndAssets: new Set(),
|
||||
@@ -211,24 +202,6 @@ export function* getPageDatasByClientOnlyID(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* From its route and component, get the page data from the build internals.
|
||||
* @param internals Build Internals with all the pages
|
||||
* @param route The route of the page, used to identify the page
|
||||
* @param component The component of the page, used to identify the page
|
||||
*/
|
||||
export function getPageData(
|
||||
internals: BuildInternals,
|
||||
route: string,
|
||||
component: string,
|
||||
): PageBuildData | undefined {
|
||||
let pageData = internals.pagesByKeys.get(makePageDataKey(route, component));
|
||||
if (pageData) {
|
||||
return pageData;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getPageDataByViteID(
|
||||
internals: BuildInternals,
|
||||
viteid: ViteID,
|
||||
@@ -247,55 +220,3 @@ export function hasPrerenderedPages(internals: BuildInternals) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
interface OrderInfo {
|
||||
depth: number;
|
||||
order: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort a page's CSS by depth. A higher depth means that the CSS comes from shared subcomponents.
|
||||
* A lower depth means it comes directly from the top-level page.
|
||||
* Can be used to sort stylesheets so that shared rules come first
|
||||
* and page-specific rules come after.
|
||||
*/
|
||||
export function cssOrder(a: OrderInfo, b: OrderInfo) {
|
||||
let depthA = a.depth,
|
||||
depthB = b.depth,
|
||||
orderA = a.order,
|
||||
orderB = b.order;
|
||||
|
||||
if (orderA === -1 && orderB >= 0) {
|
||||
return 1;
|
||||
} else if (orderB === -1 && orderA >= 0) {
|
||||
return -1;
|
||||
} else if (orderA > orderB) {
|
||||
return 1;
|
||||
} else if (orderA < orderB) {
|
||||
return -1;
|
||||
} else {
|
||||
if (depthA === -1) {
|
||||
return -1;
|
||||
} else if (depthB === -1) {
|
||||
return 1;
|
||||
} else {
|
||||
return depthA > depthB ? -1 : 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeInlineCss(
|
||||
acc: Array<StylesheetAsset>,
|
||||
current: StylesheetAsset,
|
||||
): Array<StylesheetAsset> {
|
||||
const lastAdded = acc.at(acc.length - 1);
|
||||
const lastWasInline = lastAdded?.type === 'inline';
|
||||
const currentIsInline = current?.type === 'inline';
|
||||
if (lastWasInline && currentIsInline) {
|
||||
const merged = { type: 'inline' as const, content: lastAdded.content + current.content };
|
||||
acc[acc.length - 1] = merged;
|
||||
return acc;
|
||||
}
|
||||
acc.push(current);
|
||||
return acc;
|
||||
}
|
||||
|
||||
@@ -1,54 +1,64 @@
|
||||
import { getServerOutputDirectory } from '../../prerender/utils.js';
|
||||
import type { AstroSettings, ComponentInstance } from '../../types/astro.js';
|
||||
import type { ComponentInstance } from '../../types/astro.js';
|
||||
import type { RewritePayload } from '../../types/public/common.js';
|
||||
import type {
|
||||
RouteData,
|
||||
SSRElement,
|
||||
SSRLoadedRenderer,
|
||||
SSRResult,
|
||||
} from '../../types/public/internal.js';
|
||||
import type { RouteData, SSRElement, SSRResult } from '../../types/public/internal.js';
|
||||
import {
|
||||
VIRTUAL_PAGE_RESOLVED_MODULE_ID,
|
||||
} from '../../vite-plugin-pages/const.js';
|
||||
import { getVirtualModulePageName } from '../../vite-plugin-pages/util.js';
|
||||
import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js';
|
||||
import { createConsoleLogger } from '../app/index.js';
|
||||
import type { SSRManifest } from '../app/types.js';
|
||||
import type { TryRewriteResult } from '../base-pipeline.js';
|
||||
import { routeIsFallback, routeIsRedirect } from '../redirects/helpers.js';
|
||||
import { RedirectSinglePageBuiltModule } from '../redirects/index.js';
|
||||
import { Pipeline } from '../render/index.js';
|
||||
import { RedirectSinglePageBuiltModule } from '../redirects/component.js';
|
||||
import { Pipeline } from '../base-pipeline.js';
|
||||
import { createAssetLink, createStylesheetElementSet } from '../render/ssr-element.js';
|
||||
import { createDefaultRoutes } from '../routing/default.js';
|
||||
import { getFallbackRoute, routeIsFallback, routeIsRedirect } from '../routing/helpers.js';
|
||||
import { findRouteToRewrite } from '../routing/rewrite.js';
|
||||
import { getOutDirWithinCwd } from './common.js';
|
||||
import { type BuildInternals, cssOrder, getPageData, mergeInlineCss } from './internal.js';
|
||||
import { ASTRO_PAGE_MODULE_ID, ASTRO_PAGE_RESOLVED_MODULE_ID } from './plugins/plugin-pages.js';
|
||||
import { getPagesFromVirtualModulePageName, getVirtualModulePageName } from './plugins/util.js';
|
||||
import type { PageBuildData, SinglePageBuiltModule, StaticBuildOptions } from './types.js';
|
||||
import { i18nHasFallback } from './util.js';
|
||||
import type { BuildInternals } from './internal.js';
|
||||
import { cssOrder, mergeInlineCss, getPageData } from './runtime.js';
|
||||
import type { SinglePageBuiltModule, StaticBuildOptions } from './types.js';
|
||||
|
||||
/**
|
||||
* The build pipeline is responsible to gather the files emitted by the SSR build and generate the pages by executing these files.
|
||||
*/
|
||||
export class BuildPipeline extends Pipeline {
|
||||
#componentsInterner: WeakMap<RouteData, SinglePageBuiltModule> = new WeakMap<
|
||||
RouteData,
|
||||
SinglePageBuiltModule
|
||||
>();
|
||||
internals: BuildInternals | undefined;
|
||||
options: StaticBuildOptions | undefined;
|
||||
|
||||
getName(): string {
|
||||
return 'BuildPipeline';
|
||||
}
|
||||
|
||||
/**
|
||||
* This cache is needed to map a single `RouteData` to its file path.
|
||||
* @private
|
||||
*/
|
||||
#routesByFilePath: WeakMap<RouteData, string> = new WeakMap<RouteData, string>();
|
||||
|
||||
get outFolder() {
|
||||
return this.settings.buildOutput === 'server'
|
||||
? this.settings.config.build.server
|
||||
: getOutDirWithinCwd(this.settings.config.outDir);
|
||||
getSettings() {
|
||||
if (!this.options) {
|
||||
throw new Error('No options defined');
|
||||
}
|
||||
return this.options.settings;
|
||||
}
|
||||
|
||||
getOptions() {
|
||||
if (!this.options) {
|
||||
throw new Error('No options defined');
|
||||
}
|
||||
return this.options;
|
||||
}
|
||||
|
||||
getInternals() {
|
||||
if (!this.internals) {
|
||||
throw new Error('No internals defined');
|
||||
}
|
||||
return this.internals;
|
||||
}
|
||||
|
||||
private constructor(
|
||||
readonly internals: BuildInternals,
|
||||
readonly manifest: SSRManifest,
|
||||
readonly options: StaticBuildOptions,
|
||||
readonly config = options.settings.config,
|
||||
readonly settings = options.settings,
|
||||
readonly defaultRoutes = createDefaultRoutes(manifest),
|
||||
) {
|
||||
const resolveCache = new Map<string, string>();
|
||||
@@ -71,91 +81,34 @@ export class BuildPipeline extends Pipeline {
|
||||
resolveCache.set(specifier, assetLink);
|
||||
return assetLink;
|
||||
}
|
||||
|
||||
const serverLike = settings.buildOutput === 'server';
|
||||
const logger = createConsoleLogger(manifest.logLevel);
|
||||
// We can skip streaming in SSG for performance as writing as strings are faster
|
||||
const streaming = serverLike;
|
||||
super(
|
||||
options.logger,
|
||||
manifest,
|
||||
options.runtimeMode,
|
||||
manifest.renderers,
|
||||
resolve,
|
||||
serverLike,
|
||||
streaming,
|
||||
);
|
||||
super(logger, manifest, 'production', manifest.renderers, resolve, manifest.serverLike);
|
||||
}
|
||||
|
||||
getRoutes(): RouteData[] {
|
||||
return this.options.routesList.routes;
|
||||
return this.getOptions().routesList.routes;
|
||||
}
|
||||
|
||||
static create({
|
||||
internals,
|
||||
manifest,
|
||||
options,
|
||||
}: Pick<BuildPipeline, 'internals' | 'manifest' | 'options'>) {
|
||||
return new BuildPipeline(internals, manifest, options);
|
||||
static create({ manifest }: Pick<BuildPipeline, 'manifest'>) {
|
||||
return new BuildPipeline(manifest);
|
||||
}
|
||||
|
||||
/**
|
||||
* The SSR build emits two important files:
|
||||
* - dist/server/manifest.mjs
|
||||
* - dist/renderers.mjs
|
||||
*
|
||||
* These two files, put together, will be used to generate the pages.
|
||||
*
|
||||
* ## Errors
|
||||
*
|
||||
* It will throw errors if the previous files can't be found in the file system.
|
||||
*
|
||||
* @param staticBuildOptions
|
||||
*/
|
||||
static async retrieveManifest(
|
||||
settings: AstroSettings,
|
||||
internals: BuildInternals,
|
||||
): Promise<SSRManifest> {
|
||||
const baseDirectory = getServerOutputDirectory(settings);
|
||||
const manifestEntryUrl = new URL(
|
||||
`${internals.manifestFileName}?time=${Date.now()}`,
|
||||
baseDirectory,
|
||||
);
|
||||
const { manifest } = await import(manifestEntryUrl.toString());
|
||||
if (!manifest) {
|
||||
throw new Error(
|
||||
"Astro couldn't find the emitted manifest. This is an internal error, please file an issue.",
|
||||
);
|
||||
}
|
||||
public setInternals(internals: BuildInternals) {
|
||||
this.internals = internals;
|
||||
}
|
||||
|
||||
const renderersEntryUrl = new URL(`renderers.mjs?time=${Date.now()}`, baseDirectory);
|
||||
const renderers = await import(renderersEntryUrl.toString());
|
||||
|
||||
const middleware = internals.middlewareEntryPoint
|
||||
? async function () {
|
||||
// @ts-expect-error: the compiler can't understand the previous check
|
||||
const mod = await import(internals.middlewareEntryPoint.toString());
|
||||
return { onRequest: mod.onRequest };
|
||||
}
|
||||
: manifest.middleware;
|
||||
|
||||
if (!renderers) {
|
||||
throw new Error(
|
||||
"Astro couldn't find the emitted renderers. This is an internal error, please file an issue.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
...manifest,
|
||||
renderers: renderers.renderers as SSRLoadedRenderer[],
|
||||
middleware,
|
||||
};
|
||||
public setOptions(options: StaticBuildOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
headElements(routeData: RouteData): Pick<SSRResult, 'scripts' | 'styles' | 'links'> {
|
||||
const {
|
||||
internals,
|
||||
manifest: { assetsPrefix, base },
|
||||
settings,
|
||||
} = this;
|
||||
|
||||
const settings = this.getSettings();
|
||||
const internals = this.getInternals();
|
||||
const links = new Set<never>();
|
||||
const pageBuildData = getPageData(internals, routeData.route, routeData.component);
|
||||
const scripts = new Set<SSRElement>();
|
||||
@@ -186,6 +139,7 @@ export class BuildPipeline extends Pipeline {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { scripts, styles, links };
|
||||
}
|
||||
|
||||
@@ -195,156 +149,117 @@ export class BuildPipeline extends Pipeline {
|
||||
* It collects the routes to generate during the build.
|
||||
* It returns a map of page information and their relative entry point as a string.
|
||||
*/
|
||||
retrieveRoutesToGenerate(): Map<PageBuildData, string> {
|
||||
const pages = new Map<PageBuildData, string>();
|
||||
retrieveRoutesToGenerate(): Set<RouteData> {
|
||||
const pages = new Set<RouteData>();
|
||||
|
||||
for (const [virtualModulePageName, filePath] of this.internals.entrySpecifierToBundleMap) {
|
||||
// virtual pages are emitted with the 'plugin-pages' prefix
|
||||
if (virtualModulePageName.includes(ASTRO_PAGE_RESOLVED_MODULE_ID)) {
|
||||
let pageDatas: PageBuildData[] = [];
|
||||
pageDatas.push(
|
||||
...getPagesFromVirtualModulePageName(
|
||||
this.internals,
|
||||
ASTRO_PAGE_RESOLVED_MODULE_ID,
|
||||
virtualModulePageName,
|
||||
),
|
||||
);
|
||||
for (const pageData of pageDatas) {
|
||||
pages.set(pageData, filePath);
|
||||
}
|
||||
// Keep a list of the default routes names for faster lookup
|
||||
const defaultRouteComponents = new Set(this.defaultRoutes.map(route => route.component));
|
||||
|
||||
for (const { routeData } of this.manifest.routes) {
|
||||
if (routeIsRedirect(routeData)) {
|
||||
// the component path isn't really important for redirects
|
||||
pages.add(routeData);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
for (const pageData of this.internals.pagesByKeys.values()) {
|
||||
if (routeIsRedirect(pageData.route)) {
|
||||
pages.set(pageData, pageData.component);
|
||||
} else if (
|
||||
routeIsFallback(pageData.route) &&
|
||||
(i18nHasFallback(this.config) ||
|
||||
(routeIsFallback(pageData.route) && pageData.route.route === '/'))
|
||||
) {
|
||||
// The original component is transformed during the first build, so we have to retrieve
|
||||
// the actual `.mjs` that was created.
|
||||
// During the build, we transform the names of our pages with some weird name, and those weird names become the keys of a map.
|
||||
// The values of the map are the actual `.mjs` files that are generated during the build
|
||||
|
||||
// Here, we take the component path and transform it in the virtual module name
|
||||
const moduleSpecifier = getVirtualModulePageName(ASTRO_PAGE_MODULE_ID, pageData.component);
|
||||
// We retrieve the original JS module
|
||||
const filePath = this.internals.entrySpecifierToBundleMap.get(moduleSpecifier);
|
||||
if (filePath) {
|
||||
// it exists, added it to pages to render, using the file path that we just retrieved
|
||||
pages.set(pageData, filePath);
|
||||
}
|
||||
if (routeIsFallback(routeData) && i18nHasFallback(this.manifest)) {
|
||||
pages.add(routeData);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [buildData, filePath] of pages.entries()) {
|
||||
this.#routesByFilePath.set(buildData.route, filePath);
|
||||
// Default routes like the server islands route, should not be generated
|
||||
if(defaultRouteComponents.has(routeData.component)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// A regular page, add it to the set
|
||||
pages.add(routeData);
|
||||
|
||||
// TODO The following is almost definitely legacy. We can remove it when we confirm
|
||||
// getComponentByRoute is not actually used.
|
||||
|
||||
// Here, we take the component path and transform it in the virtual module name
|
||||
const moduleSpecifier = getVirtualModulePageName(
|
||||
VIRTUAL_PAGE_RESOLVED_MODULE_ID,
|
||||
routeData.component,
|
||||
);
|
||||
|
||||
// We retrieve the original JS module
|
||||
const filePath = this.internals?.entrySpecifierToBundleMap.get(moduleSpecifier);
|
||||
|
||||
if (filePath) {
|
||||
// Populate the cache
|
||||
this.#routesByFilePath.set(routeData, filePath);
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
async getComponentByRoute(routeData: RouteData): Promise<ComponentInstance> {
|
||||
if (this.#componentsInterner.has(routeData)) {
|
||||
// SAFETY: checked before
|
||||
const entry = this.#componentsInterner.get(routeData)!;
|
||||
return await entry.page();
|
||||
}
|
||||
const module = await this.getModuleForRoute(routeData);
|
||||
return module.page();
|
||||
}
|
||||
|
||||
for (const route of this.defaultRoutes) {
|
||||
if (route.component === routeData.component) {
|
||||
return route.instance;
|
||||
async getModuleForRoute(route: RouteData): Promise<SinglePageBuiltModule> {
|
||||
for (const defaultRoute of this.defaultRoutes) {
|
||||
if (route.component === defaultRoute.component) {
|
||||
return {
|
||||
page: () => Promise.resolve(defaultRoute.instance),
|
||||
};
|
||||
}
|
||||
}
|
||||
let routeToProcess = route;
|
||||
if (routeIsRedirect(route)) {
|
||||
if (route.redirectRoute) {
|
||||
// This is a static redirect
|
||||
routeToProcess = route.redirectRoute;
|
||||
} else {
|
||||
// This is an external redirect, so we return a component stub
|
||||
return RedirectSinglePageBuiltModule;
|
||||
}
|
||||
} else if (routeIsFallback(route)) {
|
||||
// This is a i18n fallback route
|
||||
routeToProcess = getFallbackRoute(route, this.manifest.routes);
|
||||
}
|
||||
|
||||
// SAFETY: the pipeline calls `retrieveRoutesToGenerate`, which is in charge to fill the cache.
|
||||
const filePath = this.#routesByFilePath.get(routeData)!;
|
||||
const module = await this.retrieveSsrEntry(routeData, filePath);
|
||||
return module.page();
|
||||
if (this.manifest.pageMap) {
|
||||
const importComponentInstance = this.manifest.pageMap.get(routeToProcess.component);
|
||||
if (!importComponentInstance) {
|
||||
throw new Error(
|
||||
`Unexpectedly unable to find a component instance for route ${route.route}`,
|
||||
);
|
||||
}
|
||||
return await importComponentInstance();
|
||||
} else if (this.manifest.pageModule) {
|
||||
return this.manifest.pageModule;
|
||||
}
|
||||
throw new Error(
|
||||
"Astro couldn't find the correct page to render, probably because it wasn't correctly mapped for SSR usage. This is an internal error, please file an issue.",
|
||||
);
|
||||
}
|
||||
|
||||
async tryRewrite(payload: RewritePayload, request: Request): Promise<TryRewriteResult> {
|
||||
const { routeData, pathname, newUrl } = findRouteToRewrite({
|
||||
payload,
|
||||
request,
|
||||
routes: this.options.routesList.routes,
|
||||
trailingSlash: this.config.trailingSlash,
|
||||
buildFormat: this.config.build.format,
|
||||
base: this.config.base,
|
||||
outDir: this.serverLike ? this.manifest.buildClientDir : this.manifest.outDir,
|
||||
routes: this.manifest.routes.map((routeInfo) => routeInfo.routeData),
|
||||
trailingSlash: this.manifest.trailingSlash,
|
||||
buildFormat: this.manifest.buildFormat,
|
||||
base: this.manifest.base,
|
||||
outDir: this.manifest.serverLike ? this.manifest.buildClientDir : this.manifest.outDir,
|
||||
});
|
||||
|
||||
const componentInstance = await this.getComponentByRoute(routeData);
|
||||
return { routeData, componentInstance, newUrl, pathname };
|
||||
}
|
||||
|
||||
async retrieveSsrEntry(route: RouteData, filePath: string): Promise<SinglePageBuiltModule> {
|
||||
if (this.#componentsInterner.has(route)) {
|
||||
// SAFETY: it is checked inside the if
|
||||
return this.#componentsInterner.get(route)!;
|
||||
}
|
||||
let entry;
|
||||
if (routeIsRedirect(route)) {
|
||||
entry = await this.#getEntryForRedirectRoute(route, this.outFolder);
|
||||
} else if (routeIsFallback(route)) {
|
||||
entry = await this.#getEntryForFallbackRoute(route, this.outFolder);
|
||||
} else {
|
||||
const ssrEntryURLPage = createEntryURL(filePath, this.outFolder);
|
||||
entry = await import(ssrEntryURLPage.toString());
|
||||
}
|
||||
this.#componentsInterner.set(route, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
async #getEntryForFallbackRoute(
|
||||
route: RouteData,
|
||||
outFolder: URL,
|
||||
): Promise<SinglePageBuiltModule> {
|
||||
if (route.type !== 'fallback') {
|
||||
throw new Error(`Expected a redirect route.`);
|
||||
}
|
||||
if (route.redirectRoute) {
|
||||
const filePath = getEntryFilePath(this.internals, route.redirectRoute);
|
||||
if (filePath) {
|
||||
const url = createEntryURL(filePath, outFolder);
|
||||
const ssrEntryPage: SinglePageBuiltModule = await import(url.toString());
|
||||
return ssrEntryPage;
|
||||
}
|
||||
}
|
||||
|
||||
return RedirectSinglePageBuiltModule;
|
||||
}
|
||||
|
||||
async #getEntryForRedirectRoute(
|
||||
route: RouteData,
|
||||
outFolder: URL,
|
||||
): Promise<SinglePageBuiltModule> {
|
||||
if (route.type !== 'redirect') {
|
||||
throw new Error(`Expected a redirect route.`);
|
||||
}
|
||||
if (route.redirectRoute) {
|
||||
const filePath = getEntryFilePath(this.internals, route.redirectRoute);
|
||||
if (filePath) {
|
||||
const url = createEntryURL(filePath, outFolder);
|
||||
const ssrEntryPage: SinglePageBuiltModule = await import(url.toString());
|
||||
return ssrEntryPage;
|
||||
}
|
||||
}
|
||||
|
||||
return RedirectSinglePageBuiltModule;
|
||||
}
|
||||
}
|
||||
|
||||
function createEntryURL(filePath: string, outFolder: URL) {
|
||||
return new URL('./' + filePath + `?time=${Date.now()}`, outFolder);
|
||||
}
|
||||
function i18nHasFallback(manifest: SSRManifest): boolean {
|
||||
if (manifest.i18n && manifest.i18n.fallback) {
|
||||
// we have some fallback and the control is not none
|
||||
return Object.keys(manifest.i18n.fallback).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* For a given pageData, returns the entry file path—aka a resolved virtual module in our internals' specifiers.
|
||||
*/
|
||||
function getEntryFilePath(internals: BuildInternals, pageData: RouteData) {
|
||||
const id = '\x00' + getVirtualModulePageName(ASTRO_PAGE_MODULE_ID, pageData.component);
|
||||
return internals.entrySpecifierToBundleMap.get(id);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import type { Rollup, Plugin as VitePlugin } from 'vite';
|
||||
import type { BuildInternals } from './internal.js';
|
||||
import type { StaticBuildOptions, ViteBuildReturn } from './types.js';
|
||||
|
||||
type RollupOutputArray = Extract<ViteBuildReturn, Array<any>>;
|
||||
type OutputChunkorAsset = RollupOutputArray[number]['output'][number];
|
||||
type OutputChunk = Extract<OutputChunkorAsset, { type: 'chunk' }>;
|
||||
export type BuildTarget = 'server' | 'client';
|
||||
|
||||
type MutateChunk = (chunk: OutputChunk, targets: BuildTarget[], newCode: string) => void;
|
||||
|
||||
interface BuildBeforeHookResult {
|
||||
enforce?: 'after-user-plugins';
|
||||
vitePlugin: VitePlugin | VitePlugin[] | undefined;
|
||||
}
|
||||
|
||||
export type AstroBuildPlugin = {
|
||||
targets: BuildTarget[];
|
||||
hooks?: {
|
||||
'build:before'?: (opts: {
|
||||
target: BuildTarget;
|
||||
input: Set<string>;
|
||||
}) => BuildBeforeHookResult | Promise<BuildBeforeHookResult>;
|
||||
'build:post'?: (opts: {
|
||||
ssrOutputs: RollupOutputArray;
|
||||
clientOutputs: RollupOutputArray;
|
||||
mutate: MutateChunk;
|
||||
}) => void | Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
export function createPluginContainer(options: StaticBuildOptions, internals: BuildInternals) {
|
||||
const plugins = new Map<BuildTarget, AstroBuildPlugin[]>();
|
||||
const allPlugins = new Set<AstroBuildPlugin>();
|
||||
for (const target of ['client', 'server'] satisfies BuildTarget[]) {
|
||||
plugins.set(target, []);
|
||||
}
|
||||
|
||||
return {
|
||||
options,
|
||||
internals,
|
||||
register(plugin: AstroBuildPlugin) {
|
||||
allPlugins.add(plugin);
|
||||
for (const target of plugin.targets) {
|
||||
const targetPlugins = plugins.get(target) ?? [];
|
||||
targetPlugins.push(plugin);
|
||||
plugins.set(target, targetPlugins);
|
||||
}
|
||||
},
|
||||
|
||||
// Hooks
|
||||
async runBeforeHook(target: BuildTarget, input: Set<string>) {
|
||||
let targetPlugins = plugins.get(target) ?? [];
|
||||
let vitePlugins: Array<VitePlugin | VitePlugin[]> = [];
|
||||
let lastVitePlugins: Array<VitePlugin | VitePlugin[]> = [];
|
||||
for (const plugin of targetPlugins) {
|
||||
if (plugin.hooks?.['build:before']) {
|
||||
let result = await plugin.hooks['build:before']({ target, input });
|
||||
if (result.vitePlugin) {
|
||||
vitePlugins.push(result.vitePlugin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
vitePlugins,
|
||||
lastVitePlugins,
|
||||
};
|
||||
},
|
||||
|
||||
async runPostHook(ssrOutputs: Rollup.RollupOutput[], clientOutputs: Rollup.RollupOutput[]) {
|
||||
const mutations = new Map<
|
||||
string,
|
||||
{
|
||||
targets: BuildTarget[];
|
||||
code: string;
|
||||
}
|
||||
>();
|
||||
|
||||
const mutate: MutateChunk = (chunk, targets, newCode) => {
|
||||
chunk.code = newCode;
|
||||
mutations.set(chunk.fileName, {
|
||||
targets,
|
||||
code: newCode,
|
||||
});
|
||||
};
|
||||
|
||||
for (const plugin of allPlugins) {
|
||||
const postHook = plugin.hooks?.['build:post'];
|
||||
if (postHook) {
|
||||
await postHook({
|
||||
ssrOutputs,
|
||||
clientOutputs,
|
||||
mutate,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return mutations;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type AstroBuildPluginContainer = ReturnType<typeof createPluginContainer>;
|
||||
@@ -1,34 +1,33 @@
|
||||
import { astroConfigBuildPlugin } from '../../../content/vite-plugin-content-assets.js';
|
||||
import type { Plugin as VitePlugin } from 'vite';
|
||||
import { vitePluginActionsBuild } from '../../../actions/vite-plugin-actions.js';
|
||||
import { astroHeadBuildPlugin } from '../../../vite-plugin-head/index.js';
|
||||
import type { AstroBuildPluginContainer } from '../plugin.js';
|
||||
import { pluginActions } from './plugin-actions.js';
|
||||
import type { BuildInternals } from '../internal.js';
|
||||
import type { StaticBuildOptions } from '../types.js';
|
||||
import { pluginAnalyzer } from './plugin-analyzer.js';
|
||||
import { pluginChunks } from './plugin-chunks.js';
|
||||
import { pluginComponentEntry } from './plugin-component-entry.js';
|
||||
import { pluginCSS } from './plugin-css.js';
|
||||
import { pluginInternals } from './plugin-internals.js';
|
||||
import { pluginManifest } from './plugin-manifest.js';
|
||||
import { pluginMiddleware } from './plugin-middleware.js';
|
||||
import { pluginPages } from './plugin-pages.js';
|
||||
import { pluginPrerender } from './plugin-prerender.js';
|
||||
import { pluginRenderers } from './plugin-renderers.js';
|
||||
import { pluginScripts } from './plugin-scripts.js';
|
||||
import { pluginSSR } from './plugin-ssr.js';
|
||||
import { pluginNoop } from './plugin-noop.js';
|
||||
|
||||
export function registerAllPlugins({ internals, options, register }: AstroBuildPluginContainer) {
|
||||
register(pluginComponentEntry(internals));
|
||||
register(pluginAnalyzer(internals));
|
||||
register(pluginInternals(options, internals));
|
||||
register(pluginManifest(options, internals));
|
||||
register(pluginRenderers(options));
|
||||
register(pluginMiddleware(options, internals));
|
||||
register(pluginActions(options, internals));
|
||||
register(pluginPages(options, internals));
|
||||
register(pluginCSS(options, internals));
|
||||
register(astroHeadBuildPlugin(internals));
|
||||
register(pluginPrerender(options, internals));
|
||||
register(astroConfigBuildPlugin(options, internals));
|
||||
register(pluginScripts(internals));
|
||||
register(pluginSSR(options, internals));
|
||||
register(pluginChunks());
|
||||
export function getAllBuildPlugins(
|
||||
internals: BuildInternals,
|
||||
options: StaticBuildOptions,
|
||||
): Array<VitePlugin | VitePlugin[] | undefined> {
|
||||
return [
|
||||
pluginComponentEntry(internals),
|
||||
pluginAnalyzer(internals),
|
||||
pluginInternals(options, internals),
|
||||
pluginMiddleware(options, internals),
|
||||
vitePluginActionsBuild(options, internals),
|
||||
...pluginCSS(options, internals),
|
||||
astroHeadBuildPlugin(internals),
|
||||
pluginPrerender(options, internals),
|
||||
pluginScripts(internals),
|
||||
...pluginSSR(options, internals),
|
||||
pluginNoop(),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { vitePluginActionsBuild } from '../../../actions/vite-plugin-actions.js';
|
||||
import type { BuildInternals } from '../internal.js';
|
||||
import type { AstroBuildPlugin } from '../plugin.js';
|
||||
import type { StaticBuildOptions } from '../types.js';
|
||||
|
||||
export function pluginActions(
|
||||
opts: StaticBuildOptions,
|
||||
internals: BuildInternals,
|
||||
): AstroBuildPlugin {
|
||||
return {
|
||||
targets: ['server'],
|
||||
hooks: {
|
||||
'build:before': () => {
|
||||
return {
|
||||
vitePlugin: vitePluginActionsBuild(opts, internals),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -7,11 +7,17 @@ import {
|
||||
trackClientOnlyPageDatas,
|
||||
trackScriptPageDatas,
|
||||
} from '../internal.js';
|
||||
import type { AstroBuildPlugin } from '../plugin.js';
|
||||
import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../../constants.js';
|
||||
|
||||
function vitePluginAnalyzer(internals: BuildInternals): VitePlugin {
|
||||
export function pluginAnalyzer(internals: BuildInternals): VitePlugin {
|
||||
return {
|
||||
name: '@astro/rollup-plugin-astro-analyzer',
|
||||
applyToEnvironment(environment) {
|
||||
return (
|
||||
environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr ||
|
||||
environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.prerender
|
||||
);
|
||||
},
|
||||
async generateBundle() {
|
||||
const ids = this.getModuleIds();
|
||||
|
||||
@@ -83,16 +89,3 @@ function vitePluginAnalyzer(internals: BuildInternals): VitePlugin {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function pluginAnalyzer(internals: BuildInternals): AstroBuildPlugin {
|
||||
return {
|
||||
targets: ['server'],
|
||||
hooks: {
|
||||
'build:before': () => {
|
||||
return {
|
||||
vitePlugin: vitePluginAnalyzer(internals),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { Plugin as VitePlugin } from 'vite';
|
||||
import type { AstroBuildPlugin } from '../plugin.js';
|
||||
import { extendManualChunks } from './util.js';
|
||||
|
||||
function vitePluginChunks(): VitePlugin {
|
||||
return {
|
||||
name: 'astro:chunks',
|
||||
outputOptions(outputOptions) {
|
||||
extendManualChunks(outputOptions, {
|
||||
after(id) {
|
||||
// Place Astro's server runtime in a single `astro/server.mjs` file
|
||||
if (id.includes('astro/dist/runtime/server/')) {
|
||||
return 'astro/server';
|
||||
}
|
||||
// Split the Astro runtime into a separate chunk for readability
|
||||
if (id.includes('astro/dist/runtime')) {
|
||||
return 'astro';
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Build plugin that configures specific chunking behavior
|
||||
export function pluginChunks(): AstroBuildPlugin {
|
||||
return {
|
||||
targets: ['server'],
|
||||
hooks: {
|
||||
'build:before': () => {
|
||||
return {
|
||||
vitePlugin: vitePluginChunks(),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Plugin as VitePlugin } from 'vite';
|
||||
import type { BuildInternals } from '../internal.js';
|
||||
import type { AstroBuildPlugin } from '../plugin.js';
|
||||
import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../../constants.js';
|
||||
|
||||
const astroEntryPrefix = '\0astro-entry:';
|
||||
|
||||
@@ -9,7 +9,7 @@ const astroEntryPrefix = '\0astro-entry:';
|
||||
* of the export names, e.g. `import { Counter } from './ManyComponents.jsx'`. This plugin proxies
|
||||
* entries to re-export only the names the user is using.
|
||||
*/
|
||||
function vitePluginComponentEntry(internals: BuildInternals): VitePlugin {
|
||||
export function pluginComponentEntry(internals: BuildInternals): VitePlugin {
|
||||
const componentToExportNames = new Map<string, string[]>();
|
||||
|
||||
mergeComponentExportNames(internals.discoveredHydratedComponents);
|
||||
@@ -39,6 +39,9 @@ function vitePluginComponentEntry(internals: BuildInternals): VitePlugin {
|
||||
return {
|
||||
name: '@astro/plugin-component-entry',
|
||||
enforce: 'pre',
|
||||
applyToEnvironment(environment) {
|
||||
return environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.client;
|
||||
},
|
||||
config(config) {
|
||||
const rollupInput = config.build?.rollupOptions?.input;
|
||||
// Astro passes an array of inputs by default. Even though other Vite plugins could
|
||||
@@ -76,16 +79,3 @@ function vitePluginComponentEntry(internals: BuildInternals): VitePlugin {
|
||||
export function normalizeEntryId(id: string): string {
|
||||
return id.startsWith(astroEntryPrefix) ? id.slice(astroEntryPrefix.length) : id;
|
||||
}
|
||||
|
||||
export function pluginComponentEntry(internals: BuildInternals): AstroBuildPlugin {
|
||||
return {
|
||||
targets: ['client'],
|
||||
hooks: {
|
||||
'build:before': () => {
|
||||
return {
|
||||
vitePlugin: vitePluginComponentEntry(internals),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { GetModuleInfo } from 'rollup';
|
||||
import type { BuildOptions, ResolvedConfig, Plugin as VitePlugin } from 'vite';
|
||||
import { isCSSRequest } from 'vite';
|
||||
import { hasAssetPropagationFlag } from '../../../content/index.js';
|
||||
import { isBuildableCSSRequest } from '../../../vite-plugin-astro-server/util.js';
|
||||
import * as assetName from '../css-asset-name.js';
|
||||
import {
|
||||
getParentExtendedModuleInfos,
|
||||
getParentModuleInfos,
|
||||
@@ -10,42 +9,26 @@ import {
|
||||
} from '../graph.js';
|
||||
import type { BuildInternals } from '../internal.js';
|
||||
import { getPageDataByViteID, getPageDatasByClientOnlyID } from '../internal.js';
|
||||
import type { AstroBuildPlugin, BuildTarget } from '../plugin.js';
|
||||
import type { PageBuildData, StaticBuildOptions, StylesheetAsset } from '../types.js';
|
||||
import { extendManualChunks, shouldInlineAsset } from './util.js';
|
||||
import { shouldInlineAsset } from './util.js';
|
||||
import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../../constants.js';
|
||||
|
||||
/***** ASTRO PLUGIN *****/
|
||||
|
||||
export function pluginCSS(options: StaticBuildOptions, internals: BuildInternals): VitePlugin[] {
|
||||
return rollupPluginAstroBuildCSS({
|
||||
buildOptions: options,
|
||||
internals,
|
||||
});
|
||||
}
|
||||
|
||||
/***** ROLLUP SUB-PLUGINS *****/
|
||||
|
||||
interface PluginOptions {
|
||||
internals: BuildInternals;
|
||||
buildOptions: StaticBuildOptions;
|
||||
target: BuildTarget;
|
||||
}
|
||||
|
||||
/***** ASTRO PLUGIN *****/
|
||||
|
||||
export function pluginCSS(
|
||||
options: StaticBuildOptions,
|
||||
internals: BuildInternals,
|
||||
): AstroBuildPlugin {
|
||||
return {
|
||||
targets: ['client', 'server'],
|
||||
hooks: {
|
||||
'build:before': ({ target }) => {
|
||||
let plugins = rollupPluginAstroBuildCSS({
|
||||
buildOptions: options,
|
||||
internals,
|
||||
target,
|
||||
});
|
||||
|
||||
return {
|
||||
vitePlugin: plugins,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/***** ROLLUP SUB-PLUGINS *****/
|
||||
|
||||
function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] {
|
||||
const { internals, buildOptions } = options;
|
||||
const { settings } = buildOptions;
|
||||
@@ -56,52 +39,81 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] {
|
||||
const pagesToCss: Record<string, Record<string, { order: number; depth: number }>> = {};
|
||||
// Map of module Ids (usually something like `/Users/...blog.mdx?astroPropagatedAssets`) to its imported CSS
|
||||
const moduleIdToPropagatedCss: Record<string, Set<string>> = {};
|
||||
// Keep track of CSS that has been bundled to avoid duplication between ssr and prerender.
|
||||
const cssModulesInBundles = new Set();
|
||||
|
||||
const cssBuildPlugin: VitePlugin = {
|
||||
name: 'astro:rollup-plugin-build-css',
|
||||
|
||||
outputOptions(outputOptions) {
|
||||
const assetFileNames = outputOptions.assetFileNames;
|
||||
const namingIncludesHash = assetFileNames?.toString().includes('[hash]');
|
||||
const createNameForParentPages = namingIncludesHash
|
||||
? assetName.shortHashedName(settings)
|
||||
: assetName.createSlugger(settings);
|
||||
applyToEnvironment(environment) {
|
||||
return (
|
||||
environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.client ||
|
||||
environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr ||
|
||||
environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.prerender
|
||||
);
|
||||
},
|
||||
|
||||
extendManualChunks(outputOptions, {
|
||||
after(id, meta) {
|
||||
// For CSS, create a hash of all of the pages that use it.
|
||||
// This causes CSS to be built into shared chunks when used by multiple pages.
|
||||
if (isBuildableCSSRequest(id)) {
|
||||
// For client builds that has hydrated components as entrypoints, there's no way
|
||||
// to crawl up and find the pages that use it. So we lookup the cache during SSR
|
||||
// build (that has the pages information) to derive the same chunk id so they
|
||||
// match up on build, making sure both builds has the CSS deduped.
|
||||
// NOTE: Components that are only used with `client:only` may not exist in the cache
|
||||
// and that's okay. We can use Rollup's default chunk strategy instead as these CSS
|
||||
// are outside of the SSR build scope, which no dedupe is needed.
|
||||
if (options.target === 'client') {
|
||||
return internals.cssModuleToChunkIdMap.get(id)!;
|
||||
}
|
||||
|
||||
const ctx = { getModuleInfo: meta.getModuleInfo };
|
||||
for (const pageInfo of getParentModuleInfos(id, ctx)) {
|
||||
if (hasAssetPropagationFlag(pageInfo.id)) {
|
||||
// Split delayed assets to separate modules
|
||||
// so they can be injected where needed
|
||||
const chunkId = assetName.createNameHash(id, [id], settings);
|
||||
internals.cssModuleToChunkIdMap.set(id, chunkId);
|
||||
return chunkId;
|
||||
}
|
||||
}
|
||||
const chunkId = createNameForParentPages(id, meta);
|
||||
internals.cssModuleToChunkIdMap.set(id, chunkId);
|
||||
return chunkId;
|
||||
transform(_code, id) {
|
||||
if (isCSSRequest(id)) {
|
||||
// In prerender, don't rebundle CSS that was already bundled in SSR.
|
||||
// Return an empty string here to prevent it.
|
||||
if (this.environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.prerender) {
|
||||
if (cssModulesInBundles.has(id)) {
|
||||
return {
|
||||
code: '',
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
cssModulesInBundles.add(id);
|
||||
}
|
||||
},
|
||||
|
||||
async generateBundle(_outputOptions, bundle) {
|
||||
// Collect CSS modules that were bundled during SSR build for deduplication in client build
|
||||
if (
|
||||
this.environment?.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr ||
|
||||
this.environment?.name === ASTRO_VITE_ENVIRONMENT_NAMES.prerender
|
||||
) {
|
||||
for (const [, chunk] of Object.entries(bundle)) {
|
||||
if (chunk.type !== 'chunk') continue;
|
||||
|
||||
// Track all CSS modules that are bundled during SSR
|
||||
// so we can avoid creating separate CSS files for them in client build
|
||||
for (const moduleId of Object.keys(chunk.modules || {})) {
|
||||
if (isCSSRequest(moduleId)) {
|
||||
internals.cssModuleToChunkIdMap.set(moduleId, chunk.fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove CSS files from client bundle that were already bundled with pages during SSR
|
||||
if (this.environment?.name === ASTRO_VITE_ENVIRONMENT_NAMES.client) {
|
||||
for (const [, item] of Object.entries(bundle)) {
|
||||
if (item.type !== 'chunk') continue;
|
||||
if ('viteMetadata' in item === false) continue;
|
||||
const meta = item.viteMetadata as ViteMetadata;
|
||||
|
||||
// Check if this chunk contains CSS modules that were already in SSR
|
||||
const allModules = Object.keys(item.modules || {});
|
||||
const cssModules = allModules.filter((m) => isCSSRequest(m));
|
||||
|
||||
if (cssModules.length > 0) {
|
||||
// Check if ALL CSS modules in this chunk were already bundled in SSR
|
||||
const allCssInSSR = cssModules.every((moduleId) =>
|
||||
internals.cssModuleToChunkIdMap.has(moduleId),
|
||||
);
|
||||
|
||||
if (allCssInSSR && shouldDeleteCSSChunk(allModules, internals)) {
|
||||
// Delete the CSS assets that were imported by this chunk
|
||||
for (const cssId of meta.importedCss) {
|
||||
delete bundle[cssId];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [, chunk] of Object.entries(bundle)) {
|
||||
if (chunk.type !== 'chunk') continue;
|
||||
if ('viteMetadata' in chunk === false) continue;
|
||||
@@ -113,7 +125,7 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] {
|
||||
// For the client build, client:only styles need to be mapped
|
||||
// over to their page. For this chunk, determine if it's a child of a
|
||||
// client:only component and if so, add its CSS to the page it belongs to.
|
||||
if (options.target === 'client') {
|
||||
if (this.environment?.name === ASTRO_VITE_ENVIRONMENT_NAMES.client) {
|
||||
for (const id of Object.keys(chunk.modules)) {
|
||||
for (const pageData of getParentClientOnlys(id, this, internals)) {
|
||||
for (const importedCssImport of meta.importedCss) {
|
||||
@@ -126,6 +138,9 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] {
|
||||
|
||||
// For this CSS chunk, walk parents until you find a page. Add the CSS to that page.
|
||||
for (const id of Object.keys(chunk.modules)) {
|
||||
// Only walk up for dependencies that are CSS
|
||||
if (!isCSSRequest(id)) continue;
|
||||
|
||||
const parentModuleInfos = getParentExtendedModuleInfos(id, this, hasAssetPropagationFlag);
|
||||
for (const { info: pageInfo, depth, order } of parentModuleInfos) {
|
||||
if (hasAssetPropagationFlag(pageInfo.id)) {
|
||||
@@ -139,7 +154,7 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] {
|
||||
if (pageData) {
|
||||
appendCSSToPage(pageData, meta, pagesToCss, depth, order);
|
||||
}
|
||||
} else if (options.target === 'client') {
|
||||
} else if (this.environment?.name === ASTRO_VITE_ENVIRONMENT_NAMES.client) {
|
||||
// For scripts, walk parents until you find a page, and add the CSS to that page.
|
||||
const pageDatas = internals.pagesByScriptId.get(pageInfo.id)!;
|
||||
if (pageDatas) {
|
||||
@@ -157,6 +172,13 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] {
|
||||
const singleCssPlugin: VitePlugin = {
|
||||
name: 'astro:rollup-plugin-single-css',
|
||||
enforce: 'post',
|
||||
applyToEnvironment(environment) {
|
||||
return (
|
||||
environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.client ||
|
||||
environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr ||
|
||||
environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.prerender
|
||||
);
|
||||
},
|
||||
configResolved(config) {
|
||||
resolvedConfig = config;
|
||||
},
|
||||
@@ -180,6 +202,13 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] {
|
||||
const inlineStylesheetsPlugin: VitePlugin = {
|
||||
name: 'astro:rollup-plugin-inline-stylesheets',
|
||||
enforce: 'post',
|
||||
applyToEnvironment(environment) {
|
||||
return (
|
||||
environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.client ||
|
||||
environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr ||
|
||||
environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.prerender
|
||||
);
|
||||
},
|
||||
configResolved(config) {
|
||||
assetsInlineLimit = config.build.assetsInlineLimit;
|
||||
},
|
||||
@@ -193,6 +222,13 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] {
|
||||
)
|
||||
return;
|
||||
|
||||
// Delete empty CSS chunks. In prerender these are likely duplicates
|
||||
// from SSR.
|
||||
if (stylesheet.source.length === 0) {
|
||||
delete bundle[id];
|
||||
return;
|
||||
}
|
||||
|
||||
const toBeInlined =
|
||||
inlineConfig === 'always'
|
||||
? true
|
||||
@@ -250,6 +286,46 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] {
|
||||
|
||||
/***** UTILITY FUNCTIONS *****/
|
||||
|
||||
/**
|
||||
* Check if a CSS chunk should be deleted. Only delete if it contains client-only or hydrated
|
||||
* components that are NOT also used on other pages.
|
||||
*/
|
||||
function shouldDeleteCSSChunk(allModules: string[], internals: BuildInternals): boolean {
|
||||
// Find all components in this chunk that are client-only or hydrated
|
||||
const componentPaths = new Set<string>();
|
||||
|
||||
for (const componentPath of internals.discoveredClientOnlyComponents.keys()) {
|
||||
if (allModules.some((m) => m.includes(componentPath))) {
|
||||
componentPaths.add(componentPath);
|
||||
}
|
||||
}
|
||||
|
||||
for (const componentPath of internals.discoveredHydratedComponents.keys()) {
|
||||
if (allModules.some((m) => m.includes(componentPath))) {
|
||||
componentPaths.add(componentPath);
|
||||
}
|
||||
}
|
||||
|
||||
// If no special components found, don't delete
|
||||
if (componentPaths.size === 0) return false;
|
||||
|
||||
// Check if any component is used on non-client-only pages
|
||||
for (const componentPath of componentPaths) {
|
||||
const pagesUsingClientOnly = internals.pagesByClientOnly.get(componentPath);
|
||||
if (pagesUsingClientOnly) {
|
||||
// If every page using this component is in the client-only set, it's safe to delete
|
||||
// Otherwise, keep the CSS for pages that use it normally
|
||||
for (const pageData of internals.pagesByKeys.values()) {
|
||||
if (!pagesUsingClientOnly.has(pageData)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function* getParentClientOnlys(
|
||||
id: string,
|
||||
ctx: { getModuleInfo: GetModuleInfo },
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
import type { Plugin as VitePlugin } from 'vite';
|
||||
import type { EnvironmentOptions, Plugin as VitePlugin } from 'vite';
|
||||
import type { BuildInternals } from '../internal.js';
|
||||
import type { AstroBuildPlugin } from '../plugin.js';
|
||||
import type { StaticBuildOptions } from '../types.js';
|
||||
import { normalizeEntryId } from './plugin-component-entry.js';
|
||||
import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../../constants.js';
|
||||
|
||||
function vitePluginInternals(
|
||||
input: Set<string>,
|
||||
opts: StaticBuildOptions,
|
||||
export function pluginInternals(
|
||||
options: StaticBuildOptions,
|
||||
internals: BuildInternals,
|
||||
): VitePlugin {
|
||||
let input: Set<string>;
|
||||
|
||||
return {
|
||||
name: '@astro/plugin-build-internals',
|
||||
|
||||
config(config, options) {
|
||||
if (options.command === 'build' && config.build?.ssr) {
|
||||
applyToEnvironment(environment) {
|
||||
return (
|
||||
environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.client ||
|
||||
environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr ||
|
||||
environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.prerender
|
||||
);
|
||||
},
|
||||
|
||||
configEnvironment(environmentName): EnvironmentOptions | undefined {
|
||||
// Prender environment is only enabled during the build
|
||||
if (environmentName === ASTRO_VITE_ENVIRONMENT_NAMES.prerender) {
|
||||
return {
|
||||
ssr: {
|
||||
resolve: {
|
||||
// Always bundle Astro runtime when building for SSR
|
||||
noExternal: ['astro'],
|
||||
// Except for these packages as they're not bundle-friendly. Users with strict package installations
|
||||
@@ -28,10 +38,25 @@ function vitePluginInternals(
|
||||
}
|
||||
},
|
||||
|
||||
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) {
|
||||
const promises = [];
|
||||
const mapping = new Map<string, Set<string>>();
|
||||
for (const specifier of input) {
|
||||
const allInput = new Set([...input, ...internals.clientInput]);
|
||||
for (const specifier of allInput) {
|
||||
promises.push(
|
||||
this.resolve(specifier).then((result) => {
|
||||
if (result) {
|
||||
@@ -46,7 +71,7 @@ function vitePluginInternals(
|
||||
}
|
||||
await Promise.all(promises);
|
||||
for (const [_, chunk] of Object.entries(bundle)) {
|
||||
if (chunk.fileName.startsWith(opts.settings.config.build.assets)) {
|
||||
if (chunk.fileName.startsWith(options.settings.config.build.assets)) {
|
||||
internals.clientChunksAndAssets.add(chunk.fileName);
|
||||
}
|
||||
|
||||
@@ -60,19 +85,3 @@ function vitePluginInternals(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function pluginInternals(
|
||||
options: StaticBuildOptions,
|
||||
internals: BuildInternals,
|
||||
): AstroBuildPlugin {
|
||||
return {
|
||||
targets: ['client', 'server'],
|
||||
hooks: {
|
||||
'build:before': ({ input }) => {
|
||||
return {
|
||||
vitePlugin: vitePluginInternals(input, options, internals),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { OutputChunk } from 'rollup';
|
||||
import { glob } from 'tinyglobby';
|
||||
import { type BuiltinDriverName, builtinDrivers } from 'unstorage';
|
||||
import type { Plugin as VitePlugin } from 'vite';
|
||||
import type * as vite from 'vite';
|
||||
import { getAssetsPrefix } from '../../../assets/utils/getAssetsPrefix.js';
|
||||
import { normalizeTheLocale } from '../../../i18n/index.js';
|
||||
import { toFallbackType, toRoutingStrategy } from '../../../i18n/utils.js';
|
||||
import { runHookBuildSsr } from '../../../integrations/hooks.js';
|
||||
import { SERIALIZED_MANIFEST_RESOLVED_ID } from '../../../manifest/serialized.js';
|
||||
import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from '../../../vite-plugin-scripts/index.js';
|
||||
import { toFallbackType } from '../../app/common.js';
|
||||
import { serializeRouteData, toRoutingStrategy } from '../../app/index.js';
|
||||
import type {
|
||||
SerializedRouteInfo,
|
||||
SerializedSSRManifest,
|
||||
@@ -29,136 +30,120 @@ import {
|
||||
import { encodeKey } from '../../encryption.js';
|
||||
import { fileExtension, joinPaths, prependForwardSlash } from '../../path.js';
|
||||
import { DEFAULT_COMPONENTS } from '../../routing/default.js';
|
||||
import { serializeRouteData } from '../../routing/index.js';
|
||||
import { addRollupInput } from '../add-rollup-input.js';
|
||||
import { getOutFile, getOutFolder } from '../common.js';
|
||||
import { type BuildInternals, cssOrder, mergeInlineCss } from '../internal.js';
|
||||
import type { AstroBuildPlugin } from '../plugin.js';
|
||||
import type { BuildInternals } from '../internal.js';
|
||||
import { cssOrder, mergeInlineCss } from '../runtime.js';
|
||||
import type { StaticBuildOptions } from '../types.js';
|
||||
import { makePageDataKey } from './util.js';
|
||||
|
||||
const manifestReplace = '@@ASTRO_MANIFEST_REPLACE@@';
|
||||
const replaceExp = new RegExp(`['"]${manifestReplace}['"]`, 'g');
|
||||
/**
|
||||
* Unified manifest system architecture:
|
||||
*
|
||||
* The serialized manifest (virtual:astro:manifest) is now the single source of truth
|
||||
* for both dev and production builds:
|
||||
*
|
||||
* - In dev: The serialized manifest is used directly (pre-computed manifest data)
|
||||
* - In prod: Two-stage process:
|
||||
* 1. serialized.ts emits a placeholder (MANIFEST_REPLACE token) during bundling
|
||||
* 2. plugin-manifest injects the real build-specific data at the end
|
||||
*
|
||||
* This flow eliminates dual virtual modules and simplifies the architecture:
|
||||
* - pluginManifestBuild: Registers SERIALIZED_MANIFEST_ID as Vite input
|
||||
* - pluginManifestBuild.generateBundle: Tracks the serialized manifest chunk filename
|
||||
* - manifestBuildPostHook: Finds the chunk, computes final manifest data, and replaces the token
|
||||
*
|
||||
* The placeholder mechanism allows serialized.ts to emit during vite build without knowing
|
||||
* the final build-specific data (routes, assets, CSP hashes, etc) that's only available
|
||||
* after bundling completes.
|
||||
*/
|
||||
|
||||
export const SSR_MANIFEST_VIRTUAL_MODULE_ID = '@astrojs-manifest';
|
||||
export const RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID = '\0' + SSR_MANIFEST_VIRTUAL_MODULE_ID;
|
||||
export const MANIFEST_REPLACE = '@@ASTRO_MANIFEST_REPLACE@@';
|
||||
const replaceExp = new RegExp(`['"]${MANIFEST_REPLACE}['"]`, 'g');
|
||||
|
||||
function resolveSessionDriver(driver: string | undefined): string | null {
|
||||
if (!driver) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (driver === 'fs') {
|
||||
return import.meta.resolve(builtinDrivers.fsLite, import.meta.url);
|
||||
}
|
||||
if (driver in builtinDrivers) {
|
||||
return import.meta.resolve(builtinDrivers[driver as BuiltinDriverName], import.meta.url);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Post-build hook that injects the computed manifest into bundled chunks.
|
||||
* Finds the serialized manifest chunk and replaces the placeholder token with real data.
|
||||
*/
|
||||
export async function manifestBuildPostHook(
|
||||
options: StaticBuildOptions,
|
||||
internals: BuildInternals,
|
||||
{
|
||||
ssrOutputs,
|
||||
prerenderOutputs,
|
||||
mutate,
|
||||
}: {
|
||||
ssrOutputs: vite.Rollup.RollupOutput[];
|
||||
prerenderOutputs: vite.Rollup.RollupOutput[];
|
||||
mutate: (chunk: OutputChunk, envs: ['server'], code: string) => void;
|
||||
},
|
||||
) {
|
||||
const manifest = await createManifest(options, internals);
|
||||
|
||||
return driver;
|
||||
}
|
||||
if (ssrOutputs.length > 0) {
|
||||
let manifestEntryChunk: OutputChunk | undefined;
|
||||
|
||||
function vitePluginManifest(options: StaticBuildOptions, internals: BuildInternals): VitePlugin {
|
||||
return {
|
||||
name: '@astro/plugin-build-manifest',
|
||||
enforce: 'post',
|
||||
options(opts) {
|
||||
return addRollupInput(opts, [SSR_MANIFEST_VIRTUAL_MODULE_ID]);
|
||||
},
|
||||
resolveId(id) {
|
||||
if (id === SSR_MANIFEST_VIRTUAL_MODULE_ID) {
|
||||
return RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID;
|
||||
}
|
||||
},
|
||||
augmentChunkHash(chunkInfo) {
|
||||
if (chunkInfo.facadeModuleId === RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID) {
|
||||
return Date.now().toString();
|
||||
}
|
||||
},
|
||||
load(id) {
|
||||
if (id === RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID) {
|
||||
const imports = [
|
||||
`import { deserializeManifest as _deserializeManifest } from 'astro/app'`,
|
||||
`import { _privateSetManifestDontUseThis } from 'astro:ssr-manifest'`,
|
||||
];
|
||||
|
||||
const resolvedDriver = resolveSessionDriver(options.settings.config.session?.driver);
|
||||
|
||||
const contents = [
|
||||
`const manifest = _deserializeManifest('${manifestReplace}');`,
|
||||
`if (manifest.sessionConfig) manifest.sessionConfig.driverModule = ${resolvedDriver ? `() => import(${JSON.stringify(resolvedDriver)})` : 'null'};`,
|
||||
`_privateSetManifestDontUseThis(manifest);`,
|
||||
];
|
||||
const exports = [`export { manifest }`];
|
||||
|
||||
return { code: [...imports, ...contents, ...exports].join('\n') };
|
||||
}
|
||||
},
|
||||
|
||||
async generateBundle(_opts, bundle) {
|
||||
for (const [chunkName, chunk] of Object.entries(bundle)) {
|
||||
// Find the serialized manifest chunk in SSR outputs
|
||||
for (const output of ssrOutputs) {
|
||||
for (const chunk of output.output) {
|
||||
if (chunk.type === 'asset') {
|
||||
continue;
|
||||
}
|
||||
if (chunk.modules[RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID]) {
|
||||
internals.manifestEntryChunk = chunk;
|
||||
delete bundle[chunkName];
|
||||
}
|
||||
if (chunkName.startsWith('manifest')) {
|
||||
internals.manifestFileName = chunkName;
|
||||
if (chunk.code && chunk.moduleIds.includes(SERIALIZED_MANIFEST_RESOLVED_ID)) {
|
||||
manifestEntryChunk = chunk as OutputChunk;
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
if (manifestEntryChunk) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export function pluginManifest(
|
||||
options: StaticBuildOptions,
|
||||
internals: BuildInternals,
|
||||
): AstroBuildPlugin {
|
||||
return {
|
||||
targets: ['server'],
|
||||
hooks: {
|
||||
'build:before': () => {
|
||||
return {
|
||||
vitePlugin: vitePluginManifest(options, internals),
|
||||
};
|
||||
},
|
||||
if (!manifestEntryChunk) {
|
||||
throw new Error(`Did not find serialized manifest chunk for SSR`);
|
||||
}
|
||||
|
||||
'build:post': async ({ mutate }) => {
|
||||
if (!internals.manifestEntryChunk) {
|
||||
throw new Error(`Did not generate an entry chunk for SSR`);
|
||||
const shouldPassMiddlewareEntryPoint =
|
||||
options.settings.adapter?.adapterFeatures?.edgeMiddleware;
|
||||
await runHookBuildSsr({
|
||||
config: options.settings.config,
|
||||
manifest,
|
||||
logger: options.logger,
|
||||
middlewareEntryPoint: shouldPassMiddlewareEntryPoint
|
||||
? internals.middlewareEntryPoint
|
||||
: undefined,
|
||||
});
|
||||
const code = injectManifest(manifest, manifestEntryChunk);
|
||||
mutate(manifestEntryChunk, ['server'], code);
|
||||
}
|
||||
|
||||
// Also inject manifest into prerender outputs if available
|
||||
if (prerenderOutputs?.length > 0) {
|
||||
let prerenderManifestChunk: OutputChunk | undefined;
|
||||
for (const output of prerenderOutputs) {
|
||||
for (const chunk of output.output) {
|
||||
if (chunk.type === 'asset') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const manifest = await createManifest(options, internals);
|
||||
const shouldPassMiddlewareEntryPoint =
|
||||
options.settings.adapter?.adapterFeatures?.edgeMiddleware;
|
||||
await runHookBuildSsr({
|
||||
config: options.settings.config,
|
||||
manifest,
|
||||
logger: options.logger,
|
||||
middlewareEntryPoint: shouldPassMiddlewareEntryPoint
|
||||
? internals.middlewareEntryPoint
|
||||
: undefined,
|
||||
});
|
||||
const code = injectManifest(manifest, internals.manifestEntryChunk);
|
||||
mutate(internals.manifestEntryChunk, ['server'], code);
|
||||
},
|
||||
},
|
||||
};
|
||||
if (chunk.code && chunk.moduleIds.includes(SERIALIZED_MANIFEST_RESOLVED_ID)) {
|
||||
prerenderManifestChunk = chunk as OutputChunk;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (prerenderManifestChunk) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (prerenderManifestChunk) {
|
||||
const prerenderCode = injectManifest(manifest, prerenderManifestChunk);
|
||||
mutate(prerenderManifestChunk, ['server'], prerenderCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createManifest(
|
||||
buildOpts: StaticBuildOptions,
|
||||
internals: BuildInternals,
|
||||
): Promise<SerializedSSRManifest> {
|
||||
if (!internals.manifestEntryChunk) {
|
||||
throw new Error(`Did not generate an entry chunk for SSR`);
|
||||
}
|
||||
|
||||
// Add assets from the client build.
|
||||
const clientStatics = new Set(
|
||||
await glob('**/*', {
|
||||
@@ -171,7 +156,8 @@ async function createManifest(
|
||||
|
||||
const staticFiles = internals.staticFiles;
|
||||
const encodedKey = await encodeKey(await buildOpts.key);
|
||||
return await buildManifest(buildOpts, internals, Array.from(staticFiles), encodedKey);
|
||||
const manifest = await buildManifest(buildOpts, internals, Array.from(staticFiles), encodedKey);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -203,6 +189,8 @@ async function buildManifest(
|
||||
const assetQueryParams = settings.adapter?.client?.assetQueryParams;
|
||||
const assetQueryString = assetQueryParams ? assetQueryParams.toString() : undefined;
|
||||
|
||||
const appendAssetQuery = (pth: string) => assetQueryString ? `${pth}?${assetQueryString}` : pth;
|
||||
|
||||
const prefixAssetPath = (pth: string) => {
|
||||
let result = '';
|
||||
if (settings.config.build.assetsPrefix) {
|
||||
@@ -233,39 +221,17 @@ async function buildManifest(
|
||||
});
|
||||
}
|
||||
|
||||
for (const route of opts.routesList.routes) {
|
||||
if (!route.prerender) continue;
|
||||
if (!route.pathname) continue;
|
||||
|
||||
const outFolder = getOutFolder(opts.settings, route.pathname, route);
|
||||
const outFile = getOutFile(opts.settings.config, outFolder, route.pathname, route);
|
||||
const file = outFile.toString().replace(opts.settings.config.build.client.toString(), '');
|
||||
routes.push({
|
||||
file,
|
||||
links: [],
|
||||
scripts: [],
|
||||
styles: [],
|
||||
routeData: serializeRouteData(route, settings.config.trailingSlash),
|
||||
});
|
||||
staticFiles.push(file);
|
||||
}
|
||||
|
||||
const needsStaticHeaders = settings.adapter?.adapterFeatures?.experimentalStaticHeaders ?? false;
|
||||
|
||||
for (const route of opts.routesList.routes) {
|
||||
const pageData = internals.pagesByKeys.get(makePageDataKey(route.route, route.component));
|
||||
if (!pageData) continue;
|
||||
|
||||
if (route.prerender && route.type !== 'redirect' && !needsStaticHeaders) {
|
||||
continue;
|
||||
}
|
||||
const scripts: SerializedRouteInfo['scripts'] = [];
|
||||
if (settings.scripts.some((script) => script.stage === 'page')) {
|
||||
const src = entryModules[PAGE_SCRIPT_ID];
|
||||
|
||||
scripts.push({
|
||||
type: 'external',
|
||||
value: prefixAssetPath(src),
|
||||
value: appendAssetQuery(src),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -275,7 +241,7 @@ async function buildManifest(
|
||||
const styles = pageData.styles
|
||||
.sort(cssOrder)
|
||||
.map(({ sheet }) => sheet)
|
||||
.map((s) => (s.type === 'external' ? { ...s, src: prefixAssetPath(s.src) } : s))
|
||||
.map((s) => (s.type === 'external' ? { ...s, src: appendAssetQuery(s.src) } : s))
|
||||
.reduce(mergeInlineCss, []);
|
||||
|
||||
routes.push({
|
||||
@@ -290,6 +256,19 @@ async function buildManifest(
|
||||
styles,
|
||||
routeData: serializeRouteData(route, settings.config.trailingSlash),
|
||||
});
|
||||
|
||||
// Add the built .html file as a staticFile
|
||||
if (route.prerender && route.pathname) {
|
||||
const outFolder = getOutFolder(opts.settings, route.pathname, route);
|
||||
const outFile = getOutFile(
|
||||
opts.settings.config.build.format,
|
||||
outFolder,
|
||||
route.pathname,
|
||||
route,
|
||||
);
|
||||
const file = outFile.toString().replace(opts.settings.config.build.client.toString(), '');
|
||||
staticFiles.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -316,6 +295,7 @@ async function buildManifest(
|
||||
locales: settings.config.i18n.locales,
|
||||
defaultLocale: settings.config.i18n.defaultLocale,
|
||||
domainLookupTable,
|
||||
domains: settings.config.i18n.domains,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -360,7 +340,7 @@ async function buildManifest(
|
||||
}
|
||||
|
||||
return {
|
||||
hrefRoot: opts.settings.config.root.toString(),
|
||||
rootDir: opts.settings.config.root.toString(),
|
||||
cacheDir: opts.settings.config.cacheDir.toString(),
|
||||
outDir: opts.settings.config.outDir.toString(),
|
||||
srcDir: opts.settings.config.srcDir.toString(),
|
||||
@@ -368,7 +348,9 @@ async function buildManifest(
|
||||
buildClientDir: opts.settings.config.build.client.toString(),
|
||||
buildServerDir: opts.settings.config.build.server.toString(),
|
||||
adapterName: opts.settings.adapter?.name ?? '',
|
||||
assetsDir: opts.settings.config.build.assets,
|
||||
routes,
|
||||
serverLike: opts.settings.buildOutput === 'server',
|
||||
site: settings.config.site,
|
||||
base: settings.config.base,
|
||||
userAssetsBase: settings.config?.vite?.base,
|
||||
@@ -386,10 +368,15 @@ async function buildManifest(
|
||||
checkOrigin:
|
||||
(settings.config.security?.checkOrigin && settings.buildOutput === 'server') ?? false,
|
||||
allowedDomains: settings.config.security?.allowedDomains,
|
||||
serverIslandNameMap: Array.from(settings.serverIslandNameMap),
|
||||
key: encodedKey,
|
||||
sessionConfig: settings.config.session,
|
||||
csp,
|
||||
devToolbar: {
|
||||
enabled: false,
|
||||
latestAstroVersion: settings.latestAstroVersion,
|
||||
debugInfoOutput: '',
|
||||
},
|
||||
internalFetchHeaders,
|
||||
logLevel: settings.logLevel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import type { Plugin as VitePlugin } from 'vite';
|
||||
import { vitePluginMiddlewareBuild } from '../../middleware/vite-plugin.js';
|
||||
import type { BuildInternals } from '../internal.js';
|
||||
import type { AstroBuildPlugin } from '../plugin.js';
|
||||
import type { StaticBuildOptions } from '../types.js';
|
||||
import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../../constants.js';
|
||||
|
||||
export function pluginMiddleware(
|
||||
opts: StaticBuildOptions,
|
||||
internals: BuildInternals,
|
||||
): AstroBuildPlugin {
|
||||
): VitePlugin {
|
||||
const plugin = vitePluginMiddlewareBuild(opts, internals);
|
||||
return {
|
||||
targets: ['server'],
|
||||
hooks: {
|
||||
'build:before': () => {
|
||||
return {
|
||||
vitePlugin: vitePluginMiddlewareBuild(opts, internals),
|
||||
};
|
||||
},
|
||||
...plugin,
|
||||
applyToEnvironment(environment) {
|
||||
return environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
33
packages/astro/src/core/build/plugins/plugin-noop.ts
Normal file
33
packages/astro/src/core/build/plugins/plugin-noop.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type * as vite from 'vite';
|
||||
|
||||
export const NOOP_MODULE_ID = 'virtual:astro:noop';
|
||||
const RESOLVED_NOOP_MODULE_ID = '\0' + NOOP_MODULE_ID;
|
||||
|
||||
// An empty module that does nothing. This can be used as a placeholder
|
||||
// when you just need a module to be in the graph.
|
||||
// We use this for the client build when there are no client modules,
|
||||
// because the publicDir copying happens in the client build.
|
||||
export function pluginNoop(): vite.Plugin {
|
||||
return {
|
||||
name: 'plugin-noop',
|
||||
resolveId(id) {
|
||||
if(id === NOOP_MODULE_ID) {
|
||||
return RESOLVED_NOOP_MODULE_ID;
|
||||
}
|
||||
},
|
||||
load(id) {
|
||||
if(id === RESOLVED_NOOP_MODULE_ID) {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
generateBundle(_options, bundle) {
|
||||
// Delete this bundle so that its not written out to disk.
|
||||
for(const [name, chunk] of Object.entries(bundle)) {
|
||||
if(chunk.type === 'asset') continue;
|
||||
if(chunk.facadeModuleId === RESOLVED_NOOP_MODULE_ID) {
|
||||
delete bundle[name];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import type { Plugin as VitePlugin } from 'vite';
|
||||
import { routeIsRedirect } from '../../redirects/index.js';
|
||||
import { addRollupInput } from '../add-rollup-input.js';
|
||||
import type { BuildInternals } from '../internal.js';
|
||||
import type { AstroBuildPlugin } from '../plugin.js';
|
||||
import type { StaticBuildOptions } from '../types.js';
|
||||
import { RENDERERS_MODULE_ID } from './plugin-renderers.js';
|
||||
import { getPagesFromVirtualModulePageName, getVirtualModulePageName } from './util.js';
|
||||
|
||||
export const ASTRO_PAGE_MODULE_ID = '@astro-page:';
|
||||
export const ASTRO_PAGE_RESOLVED_MODULE_ID = '\0' + ASTRO_PAGE_MODULE_ID;
|
||||
|
||||
function vitePluginPages(opts: StaticBuildOptions, internals: BuildInternals): VitePlugin {
|
||||
return {
|
||||
name: '@astro/plugin-build-pages',
|
||||
options(options) {
|
||||
if (opts.settings.buildOutput === 'static') {
|
||||
const inputs = new Set<string>();
|
||||
|
||||
for (const pageData of Object.values(opts.allPages)) {
|
||||
if (routeIsRedirect(pageData.route)) {
|
||||
continue;
|
||||
}
|
||||
inputs.add(getVirtualModulePageName(ASTRO_PAGE_MODULE_ID, pageData.component));
|
||||
}
|
||||
|
||||
return addRollupInput(options, Array.from(inputs));
|
||||
}
|
||||
},
|
||||
resolveId(id) {
|
||||
if (id.startsWith(ASTRO_PAGE_MODULE_ID)) {
|
||||
return '\0' + id;
|
||||
}
|
||||
},
|
||||
async load(id) {
|
||||
if (id.startsWith(ASTRO_PAGE_RESOLVED_MODULE_ID)) {
|
||||
const imports: string[] = [];
|
||||
const exports: string[] = [];
|
||||
const pageDatas = getPagesFromVirtualModulePageName(
|
||||
internals,
|
||||
ASTRO_PAGE_RESOLVED_MODULE_ID,
|
||||
id,
|
||||
);
|
||||
for (const pageData of pageDatas) {
|
||||
const resolvedPage = await this.resolve(pageData.moduleSpecifier);
|
||||
if (resolvedPage) {
|
||||
imports.push(`import * as _page from ${JSON.stringify(pageData.moduleSpecifier)};`);
|
||||
exports.push(`export const page = () => _page`);
|
||||
|
||||
imports.push(`import { renderers } from "${RENDERERS_MODULE_ID}";`);
|
||||
exports.push(`export { renderers };`);
|
||||
|
||||
return { code: `${imports.join('\n')}${exports.join('\n')}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function pluginPages(opts: StaticBuildOptions, internals: BuildInternals): AstroBuildPlugin {
|
||||
return {
|
||||
targets: ['server'],
|
||||
hooks: {
|
||||
'build:before': () => {
|
||||
return {
|
||||
vitePlugin: vitePluginPages(opts, internals),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,107 +1,25 @@
|
||||
import type { Rollup, Plugin as VitePlugin } from 'vite';
|
||||
import { getPrerenderMetadata } from '../../../prerender/metadata.js';
|
||||
import type { Plugin as VitePlugin } from 'vite';
|
||||
import type { BuildInternals } from '../internal.js';
|
||||
import type { AstroBuildPlugin } from '../plugin.js';
|
||||
import type { StaticBuildOptions } from '../types.js';
|
||||
import { ASTRO_PAGE_RESOLVED_MODULE_ID } from './plugin-pages.js';
|
||||
import { getPagesFromVirtualModulePageName } from './util.js';
|
||||
import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../../constants.js';
|
||||
|
||||
function vitePluginPrerender(internals: BuildInternals): VitePlugin {
|
||||
export function pluginPrerender(_opts: StaticBuildOptions, internals: BuildInternals): VitePlugin {
|
||||
return {
|
||||
name: 'astro:rollup-plugin-prerender',
|
||||
|
||||
generateBundle(_, bundle) {
|
||||
applyToEnvironment(environment) {
|
||||
return environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr;
|
||||
},
|
||||
|
||||
generateBundle() {
|
||||
const moduleIds = this.getModuleIds();
|
||||
for (const id of moduleIds) {
|
||||
const pageInfo = internals.pagesByViteID.get(id);
|
||||
if (!pageInfo) continue;
|
||||
const moduleInfo = this.getModuleInfo(id);
|
||||
if (!moduleInfo) continue;
|
||||
|
||||
const prerender = !!getPrerenderMetadata(moduleInfo);
|
||||
pageInfo.route.prerender = prerender;
|
||||
pageInfo.route.prerender = Boolean(moduleInfo?.meta?.astro?.pageOptions?.prerender);
|
||||
}
|
||||
|
||||
// Find all chunks used in the SSR runtime (that aren't used for prerendering only), then use
|
||||
// the Set to find the inverse, where chunks that are only used for prerendering. It's faster
|
||||
// to compute `internals.prerenderOnlyChunks` this way. The prerendered chunks will be deleted
|
||||
// after we finish prerendering.
|
||||
const nonPrerenderOnlyChunks = getNonPrerenderOnlyChunks(bundle, internals);
|
||||
internals.prerenderOnlyChunks = Object.values(bundle).filter((chunk) => {
|
||||
return chunk.type === 'chunk' && !nonPrerenderOnlyChunks.has(chunk);
|
||||
}) as Rollup.OutputChunk[];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getNonPrerenderOnlyChunks(bundle: Rollup.OutputBundle, internals: BuildInternals) {
|
||||
const chunks = Object.values(bundle);
|
||||
|
||||
const prerenderOnlyEntryChunks = new Set<Rollup.OutputChunk>();
|
||||
const nonPrerenderOnlyEntryChunks = new Set<Rollup.OutputChunk>();
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.type === 'chunk' && chunk.isEntry) {
|
||||
// See if this entry chunk is prerendered, if so, skip it
|
||||
if (chunk.facadeModuleId?.startsWith(ASTRO_PAGE_RESOLVED_MODULE_ID)) {
|
||||
const pageDatas = getPagesFromVirtualModulePageName(
|
||||
internals,
|
||||
ASTRO_PAGE_RESOLVED_MODULE_ID,
|
||||
chunk.facadeModuleId,
|
||||
);
|
||||
const prerender = pageDatas.every((pageData) => pageData.route.prerender);
|
||||
if (prerender) {
|
||||
prerenderOnlyEntryChunks.add(chunk);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
nonPrerenderOnlyEntryChunks.add(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
// From the `nonPrerenderedEntryChunks`, we crawl all the imports/dynamicImports to find all
|
||||
// other chunks that are use by the non-prerendered runtime
|
||||
const nonPrerenderOnlyChunks = new Set(nonPrerenderOnlyEntryChunks);
|
||||
for (const chunk of nonPrerenderOnlyChunks) {
|
||||
for (const importFileName of chunk.imports) {
|
||||
const importChunk = bundle[importFileName];
|
||||
if (importChunk?.type === 'chunk') {
|
||||
nonPrerenderOnlyChunks.add(importChunk);
|
||||
}
|
||||
}
|
||||
for (const dynamicImportFileName of chunk.dynamicImports) {
|
||||
const dynamicImportChunk = bundle[dynamicImportFileName];
|
||||
// The main server entry (entry.mjs) may import a prerender-only entry chunk, we skip in this case
|
||||
// to prevent incorrectly marking it as non-prerendered.
|
||||
if (
|
||||
dynamicImportChunk?.type === 'chunk' &&
|
||||
!prerenderOnlyEntryChunks.has(dynamicImportChunk)
|
||||
) {
|
||||
nonPrerenderOnlyChunks.add(dynamicImportChunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nonPrerenderOnlyChunks;
|
||||
}
|
||||
|
||||
export function pluginPrerender(
|
||||
opts: StaticBuildOptions,
|
||||
internals: BuildInternals,
|
||||
): AstroBuildPlugin {
|
||||
// Static output can skip prerender completely because we're already rendering all pages
|
||||
if (opts.settings.buildOutput === 'static') {
|
||||
return { targets: ['server'] };
|
||||
}
|
||||
|
||||
return {
|
||||
targets: ['server'],
|
||||
hooks: {
|
||||
'build:before': () => {
|
||||
return {
|
||||
vitePlugin: vitePluginPrerender(internals),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { Plugin as VitePlugin } from 'vite';
|
||||
import { addRollupInput } from '../add-rollup-input.js';
|
||||
import type { AstroBuildPlugin } from '../plugin.js';
|
||||
import type { StaticBuildOptions } from '../types.js';
|
||||
|
||||
export const RENDERERS_MODULE_ID = '@astro-renderers';
|
||||
export const RESOLVED_RENDERERS_MODULE_ID = `\0${RENDERERS_MODULE_ID}`;
|
||||
|
||||
function vitePluginRenderers(opts: StaticBuildOptions): VitePlugin {
|
||||
return {
|
||||
name: '@astro/plugin-renderers',
|
||||
|
||||
options(options) {
|
||||
return addRollupInput(options, [RENDERERS_MODULE_ID]);
|
||||
},
|
||||
|
||||
resolveId(id) {
|
||||
if (id === RENDERERS_MODULE_ID) {
|
||||
return RESOLVED_RENDERERS_MODULE_ID;
|
||||
}
|
||||
},
|
||||
|
||||
async load(id) {
|
||||
if (id === RESOLVED_RENDERERS_MODULE_ID) {
|
||||
if (opts.settings.renderers.length > 0) {
|
||||
const imports: string[] = [];
|
||||
const exports: string[] = [];
|
||||
let i = 0;
|
||||
let rendererItems = '';
|
||||
|
||||
for (const renderer of opts.settings.renderers) {
|
||||
const variable = `_renderer${i}`;
|
||||
imports.push(`import ${variable} from ${JSON.stringify(renderer.serverEntrypoint)};`);
|
||||
rendererItems += `Object.assign(${JSON.stringify(renderer)}, { ssr: ${variable} }),`;
|
||||
i++;
|
||||
}
|
||||
|
||||
exports.push(`export const renderers = [${rendererItems}];`);
|
||||
|
||||
return { code: `${imports.join('\n')}\n${exports.join('\n')}` };
|
||||
} else {
|
||||
return { code: `export const renderers = [];` };
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function pluginRenderers(opts: StaticBuildOptions): AstroBuildPlugin {
|
||||
return {
|
||||
targets: ['server'],
|
||||
hooks: {
|
||||
'build:before': () => {
|
||||
return {
|
||||
vitePlugin: vitePluginRenderers(opts),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,17 +1,21 @@
|
||||
import type { BuildOptions, Plugin as VitePlugin } from 'vite';
|
||||
import type { BuildInternals } from '../internal.js';
|
||||
import type { AstroBuildPlugin } from '../plugin.js';
|
||||
import { shouldInlineAsset } from './util.js';
|
||||
import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../../constants.js';
|
||||
|
||||
/**
|
||||
* Inline scripts from Astro files directly into the HTML.
|
||||
*/
|
||||
function vitePluginScripts(internals: BuildInternals): VitePlugin {
|
||||
export function pluginScripts(internals: BuildInternals): VitePlugin {
|
||||
let assetInlineLimit: NonNullable<BuildOptions['assetsInlineLimit']>;
|
||||
|
||||
return {
|
||||
name: '@astro/plugin-scripts',
|
||||
|
||||
applyToEnvironment(environment) {
|
||||
return environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.client;
|
||||
},
|
||||
|
||||
configResolved(config) {
|
||||
assetInlineLimit = config.build.assetsInlineLimit;
|
||||
},
|
||||
@@ -47,16 +51,3 @@ function vitePluginScripts(internals: BuildInternals): VitePlugin {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function pluginScripts(internals: BuildInternals): AstroBuildPlugin {
|
||||
return {
|
||||
targets: ['client'],
|
||||
hooks: {
|
||||
'build:before': () => {
|
||||
return {
|
||||
vitePlugin: vitePluginScripts(internals),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,28 +1,25 @@
|
||||
import type { Plugin as VitePlugin } from 'vite';
|
||||
import { ENTRYPOINT_VIRTUAL_MODULE_ID } from '../../../actions/consts.js';
|
||||
import type { AstroAdapter } from '../../../types/public/integrations.js';
|
||||
import { MIDDLEWARE_MODULE_ID } from '../../middleware/vite-plugin.js';
|
||||
import { routeIsRedirect } from '../../redirects/index.js';
|
||||
import { VIRTUAL_ISLAND_MAP_ID } from '../../server-islands/vite-plugin-server-islands.js';
|
||||
import { addRollupInput } from '../add-rollup-input.js';
|
||||
import type { AstroAdapter } from '../../../types/public/index.js';
|
||||
import type { BuildInternals } from '../internal.js';
|
||||
import type { AstroBuildPlugin } from '../plugin.js';
|
||||
import type { StaticBuildOptions } from '../types.js';
|
||||
import { SSR_MANIFEST_VIRTUAL_MODULE_ID } from './plugin-manifest.js';
|
||||
import { ASTRO_PAGE_MODULE_ID } from './plugin-pages.js';
|
||||
import { RENDERERS_MODULE_ID } from './plugin-renderers.js';
|
||||
import { getVirtualModulePageName } from './util.js';
|
||||
import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../../constants.js';
|
||||
|
||||
const SSR_VIRTUAL_MODULE_ID = '@astrojs-ssr-virtual-entry';
|
||||
const SSR_VIRTUAL_MODULE_ID = 'virtual:astro:legacy-ssr-entry';
|
||||
export const RESOLVED_SSR_VIRTUAL_MODULE_ID = '\0' + SSR_VIRTUAL_MODULE_ID;
|
||||
|
||||
const ADAPTER_VIRTUAL_MODULE_ID = '@astrojs-ssr-adapter';
|
||||
const ADAPTER_VIRTUAL_MODULE_ID = 'virtual:astro:adapter-entrypoint';
|
||||
const RESOLVED_ADAPTER_VIRTUAL_MODULE_ID = '\0' + ADAPTER_VIRTUAL_MODULE_ID;
|
||||
|
||||
const ADAPTER_CONFIG_VIRTUAL_MODULE_ID = 'virtual:astro:adapter-config';
|
||||
const RESOLVED_ADAPTER_CONFIG_VIRTUAL_MODULE_ID = '\0' + ADAPTER_CONFIG_VIRTUAL_MODULE_ID;
|
||||
|
||||
function vitePluginAdapter(adapter: AstroAdapter): VitePlugin {
|
||||
return {
|
||||
name: '@astrojs/vite-plugin-astro-adapter',
|
||||
enforce: 'post',
|
||||
applyToEnvironment(environment) {
|
||||
return environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr;
|
||||
},
|
||||
resolveId(id) {
|
||||
if (id === ADAPTER_VIRTUAL_MODULE_ID) {
|
||||
return RESOLVED_ADAPTER_VIRTUAL_MODULE_ID;
|
||||
@@ -30,7 +27,42 @@ function vitePluginAdapter(adapter: AstroAdapter): VitePlugin {
|
||||
},
|
||||
async load(id) {
|
||||
if (id === RESOLVED_ADAPTER_VIRTUAL_MODULE_ID) {
|
||||
return { code: `export * from ${JSON.stringify(adapter.serverEntrypoint)};` };
|
||||
const adapterEntrypointStr = JSON.stringify(adapter.serverEntrypoint);
|
||||
return {
|
||||
code: `export * from ${adapterEntrypointStr};
|
||||
import * as _serverEntrypoint from ${adapterEntrypointStr};
|
||||
export default _serverEntrypoint.default;`,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite plugin that exposes adapter configuration as a virtual module.
|
||||
* Makes adapter config (args, exports, features, entrypoint) available at runtime
|
||||
* so the adapter can access its own configuration during SSR.
|
||||
*/
|
||||
function vitePluginAdapterConfig(adapter: AstroAdapter): VitePlugin {
|
||||
return {
|
||||
name: '@astrojs/vite-plugin-astro-adapter-config',
|
||||
enforce: 'post',
|
||||
applyToEnvironment(environment) {
|
||||
return environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr;
|
||||
},
|
||||
resolveId(id) {
|
||||
if (id === ADAPTER_CONFIG_VIRTUAL_MODULE_ID) {
|
||||
return RESOLVED_ADAPTER_CONFIG_VIRTUAL_MODULE_ID;
|
||||
}
|
||||
},
|
||||
load(id) {
|
||||
if (id === RESOLVED_ADAPTER_CONFIG_VIRTUAL_MODULE_ID) {
|
||||
return {
|
||||
code: `export const args = ${adapter.args ? JSON.stringify(adapter.args, null, 2) : 'undefined'};
|
||||
export const exports = ${adapter.exports ? JSON.stringify(adapter.exports) : 'undefined'};
|
||||
export const adapterFeatures = ${adapter.adapterFeatures ? JSON.stringify(adapter.adapterFeatures, null, 2) : 'undefined'};
|
||||
export const serverEntrypoint = ${JSON.stringify(adapter.serverEntrypoint)};`,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -39,73 +71,37 @@ function vitePluginAdapter(adapter: AstroAdapter): VitePlugin {
|
||||
function vitePluginSSR(
|
||||
internals: BuildInternals,
|
||||
adapter: AstroAdapter,
|
||||
options: StaticBuildOptions,
|
||||
): VitePlugin {
|
||||
return {
|
||||
name: '@astrojs/vite-plugin-astro-ssr-server',
|
||||
enforce: 'post',
|
||||
options(opts) {
|
||||
const inputs = new Set<string>();
|
||||
|
||||
for (const pageData of Object.values(options.allPages)) {
|
||||
if (routeIsRedirect(pageData.route)) {
|
||||
continue;
|
||||
}
|
||||
inputs.add(getVirtualModulePageName(ASTRO_PAGE_MODULE_ID, pageData.component));
|
||||
}
|
||||
|
||||
const adapterServerEntrypoint = options.settings.adapter?.serverEntrypoint;
|
||||
if (adapterServerEntrypoint) {
|
||||
inputs.add(ADAPTER_VIRTUAL_MODULE_ID);
|
||||
}
|
||||
|
||||
inputs.add(SSR_VIRTUAL_MODULE_ID);
|
||||
return addRollupInput(opts, Array.from(inputs));
|
||||
applyToEnvironment(environment) {
|
||||
return environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr;
|
||||
},
|
||||
resolveId(id) {
|
||||
if (id === SSR_VIRTUAL_MODULE_ID) {
|
||||
return RESOLVED_SSR_VIRTUAL_MODULE_ID;
|
||||
}
|
||||
},
|
||||
async load(id) {
|
||||
load(id) {
|
||||
if (id === RESOLVED_SSR_VIRTUAL_MODULE_ID) {
|
||||
const { allPages } = options;
|
||||
const imports: string[] = [];
|
||||
const contents: string[] = [];
|
||||
const exports: string[] = [];
|
||||
let i = 0;
|
||||
const pageMap: string[] = [];
|
||||
|
||||
for (const pageData of Object.values(allPages)) {
|
||||
if (routeIsRedirect(pageData.route)) {
|
||||
continue;
|
||||
}
|
||||
const virtualModuleName = getVirtualModulePageName(
|
||||
ASTRO_PAGE_MODULE_ID,
|
||||
pageData.component,
|
||||
if (adapter.exports) {
|
||||
exports.push(
|
||||
...(adapter.exports?.map((name) => {
|
||||
if (name === 'default') {
|
||||
return `export default _exports.default;`;
|
||||
} else {
|
||||
return `export const ${name} = _exports['${name}'];`;
|
||||
}
|
||||
}) ?? []),
|
||||
);
|
||||
let module = await this.resolve(virtualModuleName);
|
||||
if (module) {
|
||||
const variable = `_page${i}`;
|
||||
// we need to use the non-resolved ID in order to resolve correctly the virtual module
|
||||
imports.push(`const ${variable} = () => import("${virtualModuleName}");`);
|
||||
|
||||
const pageData2 = internals.pagesByKeys.get(pageData.key);
|
||||
// Always add to pageMap even if pageData2 is missing from internals
|
||||
// This ensures error pages like 500.astro are included in the build
|
||||
pageMap.push(
|
||||
`[${JSON.stringify(pageData2?.component || pageData.component)}, ${variable}]`,
|
||||
);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
contents.push(`const pageMap = new Map([\n ${pageMap.join(',\n ')}\n]);`);
|
||||
exports.push(`export { pageMap }`);
|
||||
const middleware = await this.resolve(MIDDLEWARE_MODULE_ID);
|
||||
const ssrCode = generateSSRCode(adapter, middleware!.id);
|
||||
imports.push(...ssrCode.imports);
|
||||
contents.push(...ssrCode.contents);
|
||||
return { code: [...imports, ...contents, ...exports].join('\n') };
|
||||
|
||||
return {
|
||||
code: `import _exports from 'astro/entrypoints/legacy';\n${exports.join('\n')}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
async generateBundle(_opts, bundle) {
|
||||
@@ -115,14 +111,6 @@ function vitePluginSSR(
|
||||
internals.staticFiles.add(chunk.fileName);
|
||||
}
|
||||
}
|
||||
for (const [, chunk] of Object.entries(bundle)) {
|
||||
if (chunk.type === 'asset') {
|
||||
continue;
|
||||
}
|
||||
if (chunk.modules[RESOLVED_SSR_VIRTUAL_MODULE_ID]) {
|
||||
internals.ssrEntryChunk = chunk;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -130,82 +118,17 @@ function vitePluginSSR(
|
||||
export function pluginSSR(
|
||||
options: StaticBuildOptions,
|
||||
internals: BuildInternals,
|
||||
): AstroBuildPlugin {
|
||||
): VitePlugin[] {
|
||||
// We check before this point if there's an adapter, so we can safely assume it exists here.
|
||||
const adapter = options.settings.adapter!;
|
||||
const ssr = options.settings.buildOutput === 'server';
|
||||
return {
|
||||
targets: ['server'],
|
||||
hooks: {
|
||||
'build:before': () => {
|
||||
// We check before this point if there's an adapter, so we can safely assume it exists here.
|
||||
const adapter = options.settings.adapter!;
|
||||
const ssrPlugin = ssr && vitePluginSSR(internals, adapter, options);
|
||||
const vitePlugin = [vitePluginAdapter(adapter)];
|
||||
if (ssrPlugin) {
|
||||
vitePlugin.unshift(ssrPlugin);
|
||||
}
|
||||
|
||||
return {
|
||||
enforce: 'after-user-plugins',
|
||||
vitePlugin: vitePlugin,
|
||||
};
|
||||
},
|
||||
'build:post': async () => {
|
||||
if (!ssr) {
|
||||
return;
|
||||
}
|
||||
const plugins: VitePlugin[] = [vitePluginAdapter(adapter), vitePluginAdapterConfig(adapter)];
|
||||
|
||||
if (!internals.ssrEntryChunk) {
|
||||
throw new Error(`Did not generate an entry chunk for SSR`);
|
||||
}
|
||||
// Mutate the filename
|
||||
internals.ssrEntryChunk.fileName = options.settings.config.build.serverEntry;
|
||||
},
|
||||
},
|
||||
};
|
||||
if (ssr) {
|
||||
plugins.unshift(vitePluginSSR(internals, adapter));
|
||||
}
|
||||
|
||||
return plugins;
|
||||
}
|
||||
|
||||
function generateSSRCode(adapter: AstroAdapter, middlewareId: string) {
|
||||
const edgeMiddleware = adapter?.adapterFeatures?.edgeMiddleware ?? false;
|
||||
|
||||
const imports = [
|
||||
`import { renderers } from '${RENDERERS_MODULE_ID}';`,
|
||||
`import * as serverEntrypointModule from '${ADAPTER_VIRTUAL_MODULE_ID}';`,
|
||||
`import { manifest as defaultManifest } from '${SSR_MANIFEST_VIRTUAL_MODULE_ID}';`,
|
||||
`import { serverIslandMap } from '${VIRTUAL_ISLAND_MAP_ID}';`,
|
||||
];
|
||||
|
||||
const contents = [
|
||||
edgeMiddleware ? `const middleware = (_, next) => next()` : '',
|
||||
`const _manifest = Object.assign(defaultManifest, {`,
|
||||
` pageMap,`,
|
||||
` serverIslandMap,`,
|
||||
` renderers,`,
|
||||
` actions: () => import("${ENTRYPOINT_VIRTUAL_MODULE_ID}"),`,
|
||||
` middleware: ${edgeMiddleware ? 'undefined' : `() => import("${middlewareId}")`}`,
|
||||
`});`,
|
||||
`const _args = ${adapter.args ? JSON.stringify(adapter.args, null, 4) : 'undefined'};`,
|
||||
adapter.exports
|
||||
? `const _exports = serverEntrypointModule.createExports(_manifest, _args);`
|
||||
: '',
|
||||
...(adapter.exports?.map((name) => {
|
||||
if (name === 'default') {
|
||||
return `export default _exports.default;`;
|
||||
} else {
|
||||
return `export const ${name} = _exports['${name}'];`;
|
||||
}
|
||||
}) ?? []),
|
||||
// NOTE: This is intentionally obfuscated!
|
||||
// Do NOT simplify this to something like `serverEntrypointModule.start?.(_manifest, _args)`
|
||||
// They are NOT equivalent! Some bundlers will throw if `start` is not exported, but we
|
||||
// only want to silently ignore it... hence the dynamic, obfuscated weirdness.
|
||||
`const _start = 'start';
|
||||
if (Object.prototype.hasOwnProperty.call(serverEntrypointModule, _start)) {
|
||||
serverEntrypointModule[_start](_manifest, _args);
|
||||
}`,
|
||||
];
|
||||
|
||||
return {
|
||||
imports,
|
||||
contents,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,46 +1,4 @@
|
||||
import { extname } from 'node:path';
|
||||
import type { BuildOptions, Rollup, Plugin as VitePlugin } from 'vite';
|
||||
import type { BuildInternals } from '../internal.js';
|
||||
import type { PageBuildData } from '../types.js';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
type OutputOptionsHook = Extract<VitePlugin['outputOptions'], Function>;
|
||||
type OutputOptions = Parameters<OutputOptionsHook>[0];
|
||||
|
||||
type ExtendManualChunksHooks = {
|
||||
before?: Rollup.GetManualChunk;
|
||||
after?: Rollup.GetManualChunk;
|
||||
};
|
||||
|
||||
export function extendManualChunks(outputOptions: OutputOptions, hooks: ExtendManualChunksHooks) {
|
||||
const manualChunks = outputOptions.manualChunks;
|
||||
outputOptions.manualChunks = function (id, meta) {
|
||||
if (hooks.before) {
|
||||
let value = hooks.before(id, meta);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Defer to user-provided `manualChunks`, if it was provided.
|
||||
if (typeof manualChunks == 'object') {
|
||||
if (id in manualChunks) {
|
||||
let value = manualChunks[id];
|
||||
return value[0];
|
||||
}
|
||||
} else if (typeof manualChunks === 'function') {
|
||||
const outid = manualChunks.call(this, id, meta);
|
||||
if (outid) {
|
||||
return outid;
|
||||
}
|
||||
}
|
||||
|
||||
if (hooks.after) {
|
||||
return hooks.after(id, meta) || null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
import type { BuildOptions } from 'vite';
|
||||
|
||||
// This is an arbitrary string that we use to replace the dot of the extension.
|
||||
export const ASTRO_PAGE_EXTENSION_POST_PATTERN = '@_@';
|
||||
@@ -57,55 +15,6 @@ export function makePageDataKey(route: string, componentPath: string): string {
|
||||
return route + ASTRO_PAGE_KEY_SEPARATOR + componentPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevents Rollup from triggering other plugins in the process by masking the extension (hence the virtual file).
|
||||
* Inverse function of getComponentFromVirtualModulePageName() below.
|
||||
* @param virtualModulePrefix The prefix used to create the virtual module
|
||||
* @param path Page component path
|
||||
*/
|
||||
export function getVirtualModulePageName(virtualModulePrefix: string, path: string): string {
|
||||
const extension = extname(path);
|
||||
return (
|
||||
virtualModulePrefix +
|
||||
(extension.startsWith('.')
|
||||
? path.slice(0, -extension.length) + extension.replace('.', ASTRO_PAGE_EXTENSION_POST_PATTERN)
|
||||
: path)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* From the VirtualModulePageName, and the internals, get all pageDatas that use this
|
||||
* component as their entry point.
|
||||
* @param virtualModulePrefix The prefix used to create the virtual module
|
||||
* @param id Virtual module name
|
||||
*/
|
||||
export function getPagesFromVirtualModulePageName(
|
||||
internals: BuildInternals,
|
||||
virtualModulePrefix: string,
|
||||
id: string,
|
||||
): PageBuildData[] {
|
||||
const path = getComponentFromVirtualModulePageName(virtualModulePrefix, id);
|
||||
|
||||
const pages: PageBuildData[] = [];
|
||||
internals.pagesByKeys.forEach((pageData) => {
|
||||
if (pageData.component === path) {
|
||||
pages.push(pageData);
|
||||
}
|
||||
});
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
/**
|
||||
* From the VirtualModulePageName, get the component path.
|
||||
* Remember that the component can be use by multiple routes.
|
||||
* Inverse function of getVirtualModulePageName() above.
|
||||
* @param virtualModulePrefix The prefix at the beginning of the virtual module
|
||||
* @param id Virtual module name
|
||||
*/
|
||||
function getComponentFromVirtualModulePageName(virtualModulePrefix: string, id: string): string {
|
||||
return id.slice(virtualModulePrefix.length).replace(ASTRO_PAGE_EXTENSION_POST_PATTERN, '.');
|
||||
}
|
||||
|
||||
export function shouldInlineAsset(
|
||||
assetContent: string,
|
||||
|
||||
77
packages/astro/src/core/build/runtime.ts
Normal file
77
packages/astro/src/core/build/runtime.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { BuildInternals } from './internal.js';
|
||||
import type { PageBuildData, StylesheetAsset } from './types.js';
|
||||
import { makePageDataKey } from './plugins/util.js';
|
||||
|
||||
/**
|
||||
* From its route and component, get the page data from the build internals.
|
||||
* @param internals Build Internals with all the pages
|
||||
* @param route The route of the page, used to identify the page
|
||||
* @param component The component of the page, used to identify the page
|
||||
*/
|
||||
export function getPageData(
|
||||
internals: BuildInternals,
|
||||
route: string,
|
||||
component: string,
|
||||
): PageBuildData | undefined {
|
||||
let pageData = internals.pagesByKeys.get(makePageDataKey(route, component));
|
||||
if (pageData) {
|
||||
return pageData;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface OrderInfo {
|
||||
depth: number;
|
||||
order: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort a page's CSS by depth. A higher depth means that the CSS comes from shared subcomponents.
|
||||
* A lower depth means it comes directly from the top-level page.
|
||||
* Can be used to sort stylesheets so that shared rules come first
|
||||
* and page-specific rules come after.
|
||||
*/
|
||||
export function cssOrder(a: OrderInfo, b: OrderInfo) {
|
||||
let depthA = a.depth,
|
||||
depthB = b.depth,
|
||||
orderA = a.order,
|
||||
orderB = b.order;
|
||||
|
||||
if (orderA === -1 && orderB >= 0) {
|
||||
return 1;
|
||||
} else if (orderB === -1 && orderA >= 0) {
|
||||
return -1;
|
||||
} else if (orderA > orderB) {
|
||||
return 1;
|
||||
} else if (orderA < orderB) {
|
||||
return -1;
|
||||
} else {
|
||||
if (depthA === -1) {
|
||||
return -1;
|
||||
} else if (depthB === -1) {
|
||||
return 1;
|
||||
} else {
|
||||
return depthA > depthB ? -1 : 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges inline CSS into as few stylesheets as possible,
|
||||
* preserving ordering when there are non-inlined in between.
|
||||
*/
|
||||
export function mergeInlineCss(
|
||||
acc: Array<StylesheetAsset>,
|
||||
current: StylesheetAsset,
|
||||
): Array<StylesheetAsset> {
|
||||
const lastAdded = acc.at(acc.length - 1);
|
||||
const lastWasInline = lastAdded?.type === 'inline';
|
||||
const currentIsInline = current?.type === 'inline';
|
||||
if (lastWasInline && currentIsInline) {
|
||||
const merged = { type: 'inline' as const, content: lastAdded.content + current.content };
|
||||
acc[acc.length - 1] = merged;
|
||||
return acc;
|
||||
}
|
||||
acc.push(current);
|
||||
return acc;
|
||||
}
|
||||
@@ -1,31 +1,35 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { teardown } from '@astrojs/compiler';
|
||||
import colors from 'piccolore';
|
||||
import { glob } from 'tinyglobby';
|
||||
import * as vite from 'vite';
|
||||
import { contentAssetsBuildPostHook } from '../../content/vite-plugin-content-assets.js';
|
||||
import { type BuildInternals, createBuildInternals } from '../../core/build/internal.js';
|
||||
import { emptyDir, removeEmptyDirs } from '../../core/fs/index.js';
|
||||
import { appendForwardSlash, prependForwardSlash } from '../../core/path.js';
|
||||
import { runHookBuildSetup } from '../../integrations/hooks.js';
|
||||
import { getServerOutputDirectory } from '../../prerender/utils.js';
|
||||
import { SERIALIZED_MANIFEST_RESOLVED_ID } from '../../manifest/serialized.js';
|
||||
import { getClientOutputDirectory, getServerOutputDirectory } from '../../prerender/utils.js';
|
||||
import type { RouteData } from '../../types/public/internal.js';
|
||||
import { VIRTUAL_PAGE_RESOLVED_MODULE_ID } from '../../vite-plugin-pages/const.js';
|
||||
import { RESOLVED_ASTRO_RENDERERS_MODULE_ID } from '../../vite-plugin-renderers/index.js';
|
||||
import { PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js';
|
||||
import { routeIsRedirect } from '../redirects/index.js';
|
||||
import { routeIsRedirect } from '../routing/index.js';
|
||||
import { getOutDirWithinCwd } from './common.js';
|
||||
import { CHUNKS_PATH } from './consts.js';
|
||||
import { generatePages } from './generate.js';
|
||||
import { trackPageData } from './internal.js';
|
||||
import { type AstroBuildPluginContainer, createPluginContainer } from './plugin.js';
|
||||
import { registerAllPlugins } from './plugins/index.js';
|
||||
import { RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID } from './plugins/plugin-manifest.js';
|
||||
import { ASTRO_PAGE_RESOLVED_MODULE_ID } from './plugins/plugin-pages.js';
|
||||
import { RESOLVED_RENDERERS_MODULE_ID } from './plugins/plugin-renderers.js';
|
||||
import { getAllBuildPlugins } from './plugins/index.js';
|
||||
import { manifestBuildPostHook } from './plugins/plugin-manifest.js';
|
||||
import { RESOLVED_SSR_VIRTUAL_MODULE_ID } from './plugins/plugin-ssr.js';
|
||||
import { ASTRO_PAGE_EXTENSION_POST_PATTERN } from './plugins/util.js';
|
||||
import type { StaticBuildOptions } from './types.js';
|
||||
import { encodeName, getTimeStat, viteBuildReturnToRollupOutputs } from './util.js';
|
||||
import { NOOP_MODULE_ID } from './plugins/plugin-noop.js';
|
||||
import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../constants.js';
|
||||
|
||||
const PRERENDER_ENTRY_FILENAME_PREFIX = 'prerender-entry';
|
||||
|
||||
export async function viteBuild(opts: StaticBuildOptions) {
|
||||
const { allPages, settings } = opts;
|
||||
@@ -58,13 +62,10 @@ export async function viteBuild(opts: StaticBuildOptions) {
|
||||
emptyDir(settings.config.outDir, new Set('.git'));
|
||||
}
|
||||
|
||||
// Register plugins
|
||||
const container = createPluginContainer(opts, internals);
|
||||
registerAllPlugins(container);
|
||||
// Build your project (SSR application code, assets, client JS, etc.)
|
||||
const ssrTime = performance.now();
|
||||
opts.logger.info('build', `Building ${settings.buildOutput} entrypoints...`);
|
||||
const ssrOutput = await ssrBuild(opts, internals, pageInput, container);
|
||||
const { ssrOutput, prerenderOutput, clientOutput } = await buildEnvironments(opts, internals);
|
||||
opts.logger.info(
|
||||
'build',
|
||||
colors.green(`✓ Completed in ${getTimeStat(ssrTime, performance.now())}.`),
|
||||
@@ -72,82 +73,64 @@ export async function viteBuild(opts: StaticBuildOptions) {
|
||||
|
||||
settings.timer.end('SSR build');
|
||||
|
||||
settings.timer.start('Client build');
|
||||
|
||||
const rendererClientEntrypoints = settings.renderers
|
||||
.map((r) => r.clientEntrypoint)
|
||||
.filter((a) => typeof a === 'string') as string[];
|
||||
|
||||
const clientInput = new Set([
|
||||
...internals.discoveredHydratedComponents.keys(),
|
||||
...internals.discoveredClientOnlyComponents.keys(),
|
||||
...rendererClientEntrypoints,
|
||||
...internals.discoveredScripts,
|
||||
]);
|
||||
|
||||
if (settings.scripts.some((script) => script.stage === 'page')) {
|
||||
clientInput.add(PAGE_SCRIPT_ID);
|
||||
}
|
||||
|
||||
// Run client build first, so the assets can be fed into the SSR rendered version.
|
||||
const clientOutput = await clientBuild(opts, internals, clientInput, container);
|
||||
|
||||
// Handle ssr output for post-build hooks
|
||||
const ssrOutputs = viteBuildReturnToRollupOutputs(ssrOutput);
|
||||
const clientOutputs = viteBuildReturnToRollupOutputs(clientOutput ?? []);
|
||||
await runPostBuildHooks(container, ssrOutputs, clientOutputs);
|
||||
settings.timer.end('Client build');
|
||||
const prerenderOutputs = viteBuildReturnToRollupOutputs(prerenderOutput);
|
||||
await runManifestInjection(opts, internals, ssrOutputs, clientOutputs, prerenderOutputs);
|
||||
|
||||
// Free up memory
|
||||
internals.ssrEntryChunk = undefined;
|
||||
if (opts.teardownCompiler) {
|
||||
teardown();
|
||||
}
|
||||
// Store prerender output directory for use in page generation
|
||||
const prerenderOutputDir = new URL('./.prerender/', getServerOutputDirectory(settings));
|
||||
|
||||
// For static builds, the SSR output won't be needed anymore after page generation.
|
||||
// We keep track of the names here so we only remove these specific files when finished.
|
||||
const ssrOutputChunkNames: string[] = [];
|
||||
for (const output of ssrOutputs) {
|
||||
for (const chunk of output.output) {
|
||||
if (chunk.type === 'chunk') {
|
||||
ssrOutputChunkNames.push(chunk.fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { internals, ssrOutputChunkNames };
|
||||
return { internals, prerenderOutputDir };
|
||||
}
|
||||
|
||||
export async function staticBuild(
|
||||
opts: StaticBuildOptions,
|
||||
internals: BuildInternals,
|
||||
ssrOutputChunkNames: string[],
|
||||
prerenderOutputDir: URL,
|
||||
) {
|
||||
const { settings } = opts;
|
||||
if (settings.buildOutput === 'static') {
|
||||
settings.timer.start('Static generate');
|
||||
await generatePages(opts, internals);
|
||||
await cleanServerOutput(opts, ssrOutputChunkNames, internals);
|
||||
// Move prerender and SSR assets to client directory before cleaning up
|
||||
await ssrMoveAssets(opts, prerenderOutputDir);
|
||||
// Generate the pages
|
||||
await generatePages(opts, internals, prerenderOutputDir);
|
||||
// Clean up prerender directory after generation
|
||||
await fs.promises.rm(prerenderOutputDir, { recursive: true, force: true });
|
||||
settings.timer.end('Static generate');
|
||||
} else if (settings.buildOutput === 'server') {
|
||||
settings.timer.start('Server generate');
|
||||
await generatePages(opts, internals);
|
||||
await cleanStaticOutput(opts, internals);
|
||||
await ssrMoveAssets(opts);
|
||||
await generatePages(opts, internals, prerenderOutputDir);
|
||||
// Move prerender and SSR assets to client directory before cleaning up
|
||||
await ssrMoveAssets(opts, prerenderOutputDir);
|
||||
// Clean up prerender directory after generation
|
||||
await fs.promises.rm(prerenderOutputDir, { recursive: true, force: true });
|
||||
settings.timer.end('Server generate');
|
||||
}
|
||||
}
|
||||
|
||||
async function ssrBuild(
|
||||
opts: StaticBuildOptions,
|
||||
internals: BuildInternals,
|
||||
input: Set<string>,
|
||||
container: AstroBuildPluginContainer,
|
||||
) {
|
||||
/**
|
||||
* Builds all Vite environments (SSR, prerender, client) in sequence.
|
||||
*
|
||||
* - SSR: Built only when buildOutput='server', generates the server entry point
|
||||
* - Prerender: Always built, generates static prerenderable routes
|
||||
* - Client: Built last with discovered hydration and client-only components
|
||||
*
|
||||
* Returns outputs from each environment for post-build processing.
|
||||
*/
|
||||
async function buildEnvironments(opts: StaticBuildOptions, internals: BuildInternals) {
|
||||
const { allPages, settings, viteConfig } = opts;
|
||||
const ssr = settings.buildOutput === 'server';
|
||||
const out = getServerOutputDirectory(settings);
|
||||
const routes = Object.values(allPages).flatMap((pageData) => pageData.route);
|
||||
const { lastVitePlugins, vitePlugins } = await container.runBeforeHook('server', input);
|
||||
|
||||
// Determine if we should use the legacy-dynamic entrypoint
|
||||
const entryType = settings.adapter?.entryType ?? 'legacy-dynamic';
|
||||
const useLegacyDynamic = entryType === 'legacy-dynamic';
|
||||
|
||||
const buildPlugins = getAllBuildPlugins(internals, opts);
|
||||
const flatPlugins = buildPlugins.flat().filter(Boolean);
|
||||
|
||||
const viteBuildConfig: vite.InlineConfig = {
|
||||
...viteConfig,
|
||||
logLevel: viteConfig.logLevel ?? 'error',
|
||||
@@ -158,14 +141,13 @@ async function ssrBuild(
|
||||
cssMinify: viteConfig.build?.minify == null ? true : !!viteConfig.build?.minify,
|
||||
...viteConfig.build,
|
||||
emptyOutDir: false,
|
||||
copyPublicDir: false,
|
||||
manifest: false,
|
||||
outDir: fileURLToPath(out),
|
||||
copyPublicDir: !ssr,
|
||||
rollupOptions: {
|
||||
...viteConfig.build?.rollupOptions,
|
||||
// Setting as `exports-only` allows us to safely delete inputs that are only used during prerendering
|
||||
preserveEntrySignatures: 'exports-only',
|
||||
input: [],
|
||||
...(useLegacyDynamic ? { input: 'virtual:astro:legacy-ssr-entry' } : {}),
|
||||
output: {
|
||||
hoistTransitiveImports: false,
|
||||
format: 'esm',
|
||||
@@ -194,17 +176,17 @@ async function ssrBuild(
|
||||
assetFileNames: `${settings.config.build.assets}/[name].[hash][extname]`,
|
||||
...viteConfig.build?.rollupOptions?.output,
|
||||
entryFileNames(chunkInfo) {
|
||||
if (chunkInfo.facadeModuleId?.startsWith(ASTRO_PAGE_RESOLVED_MODULE_ID)) {
|
||||
if (chunkInfo.facadeModuleId?.startsWith(VIRTUAL_PAGE_RESOLVED_MODULE_ID)) {
|
||||
return makeAstroPageEntryPointFileName(
|
||||
ASTRO_PAGE_RESOLVED_MODULE_ID,
|
||||
VIRTUAL_PAGE_RESOLVED_MODULE_ID,
|
||||
chunkInfo.facadeModuleId,
|
||||
routes,
|
||||
);
|
||||
} else if (chunkInfo.facadeModuleId === RESOLVED_SSR_VIRTUAL_MODULE_ID) {
|
||||
return opts.settings.config.build.serverEntry;
|
||||
} else if (chunkInfo.facadeModuleId === RESOLVED_RENDERERS_MODULE_ID) {
|
||||
} else if (chunkInfo.facadeModuleId === RESOLVED_ASTRO_RENDERERS_MODULE_ID) {
|
||||
return 'renderers.mjs';
|
||||
} else if (chunkInfo.facadeModuleId === RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID) {
|
||||
} else if (chunkInfo.facadeModuleId === SERIALIZED_MANIFEST_RESOLVED_ID) {
|
||||
return 'manifest_[hash].mjs';
|
||||
} else if (chunkInfo.facadeModuleId === settings.adapter?.serverEntrypoint) {
|
||||
return 'adapter_[hash].mjs';
|
||||
@@ -221,9 +203,56 @@ async function ssrBuild(
|
||||
modulePreload: { polyfill: false },
|
||||
reportCompressedSize: false,
|
||||
},
|
||||
plugins: [...vitePlugins, ...(viteConfig.plugins || []), ...lastVitePlugins],
|
||||
plugins: [...flatPlugins, ...(viteConfig.plugins || [])],
|
||||
envPrefix: viteConfig.envPrefix ?? 'PUBLIC_',
|
||||
base: settings.config.base,
|
||||
environments: {
|
||||
...(viteConfig.environments ?? {}),
|
||||
[ASTRO_VITE_ENVIRONMENT_NAMES.prerender]: {
|
||||
build: {
|
||||
emitAssets: true,
|
||||
outDir: fileURLToPath(new URL('./.prerender/', getServerOutputDirectory(settings))),
|
||||
rollupOptions: {
|
||||
input: 'astro/entrypoints/prerender',
|
||||
output: {
|
||||
entryFileNames: `${PRERENDER_ENTRY_FILENAME_PREFIX}.[hash].mjs`,
|
||||
format: 'esm',
|
||||
...viteConfig.environments?.prerender?.build?.rollupOptions?.output,
|
||||
},
|
||||
},
|
||||
ssr: true,
|
||||
},
|
||||
},
|
||||
[ASTRO_VITE_ENVIRONMENT_NAMES.client]: {
|
||||
build: {
|
||||
emitAssets: true,
|
||||
target: 'esnext',
|
||||
outDir: fileURLToPath(getClientOutputDirectory(settings)),
|
||||
copyPublicDir: true,
|
||||
sourcemap: viteConfig.environments?.client?.build?.sourcemap ?? false,
|
||||
minify: true,
|
||||
rollupOptions: {
|
||||
preserveEntrySignatures: 'exports-only',
|
||||
output: {
|
||||
entryFileNames: `${settings.config.build.assets}/[name].[hash].js`,
|
||||
chunkFileNames: `${settings.config.build.assets}/[name].[hash].js`,
|
||||
assetFileNames: `${settings.config.build.assets}/[name].[hash][extname]`,
|
||||
...viteConfig.environments?.client?.build?.rollupOptions?.output,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
[ASTRO_VITE_ENVIRONMENT_NAMES.ssr]: {
|
||||
build: {
|
||||
outDir: fileURLToPath(getServerOutputDirectory(settings)),
|
||||
rollupOptions: {
|
||||
output: {
|
||||
...viteConfig.environments?.ssr?.build?.rollupOptions?.output,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const updatedViteBuildConfig = await runHookBuildSetup({
|
||||
@@ -234,85 +263,155 @@ async function ssrBuild(
|
||||
logger: opts.logger,
|
||||
});
|
||||
|
||||
return await vite.build(updatedViteBuildConfig);
|
||||
const builder = await vite.createBuilder(updatedViteBuildConfig);
|
||||
|
||||
// Build ssr environment for server output
|
||||
const ssrOutput =
|
||||
settings.buildOutput === 'static' ? [] : await builder.build(builder.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr]);
|
||||
|
||||
// Build prerender environment for static generation
|
||||
const prerenderOutput = await builder.build(builder.environments.prerender);
|
||||
|
||||
// Extract prerender entry filename and store in internals
|
||||
extractPrerenderEntryFileName(internals, prerenderOutput);
|
||||
|
||||
// Build client environment
|
||||
// We must discover client inputs after SSR build because hydration/client-only directives
|
||||
// are only detected during SSR. We mutate the config here since the builder was already created
|
||||
// and this is the only way to update the input after instantiation.
|
||||
internals.clientInput = getClientInput(internals, settings);
|
||||
if (!internals.clientInput.size) {
|
||||
// At least 1 input is required to do a build, otherwise Vite throws.
|
||||
// We need the client build to happen in order to copy over the `public/` folder
|
||||
// So using the noop plugin here which will give us an input that just gets thrown away.
|
||||
internals.clientInput.add(NOOP_MODULE_ID);
|
||||
}
|
||||
builder.environments.client.config.build.rollupOptions.input = Array.from(internals.clientInput);
|
||||
const clientOutput = await builder.build(builder.environments.client);
|
||||
|
||||
return { ssrOutput, prerenderOutput, clientOutput };
|
||||
}
|
||||
|
||||
async function clientBuild(
|
||||
opts: StaticBuildOptions,
|
||||
internals: BuildInternals,
|
||||
input: Set<string>,
|
||||
container: AstroBuildPluginContainer,
|
||||
) {
|
||||
const { settings, viteConfig } = opts;
|
||||
const ssr = settings.buildOutput === 'server';
|
||||
const out = ssr ? settings.config.build.client : getOutDirWithinCwd(settings.config.outDir);
|
||||
type MutateChunk = (chunk: vite.Rollup.OutputChunk, targets: string[], newCode: string) => void;
|
||||
|
||||
// Nothing to do if there is no client-side JS.
|
||||
if (!input.size) {
|
||||
// If SSR, copy public over
|
||||
if (ssr && fs.existsSync(settings.config.publicDir)) {
|
||||
await fs.promises.cp(settings.config.publicDir, out, { recursive: true, force: true });
|
||||
/**
|
||||
* Finds and returns the prerender entry filename from the build output.
|
||||
* Throws an error if no prerender entry file is found.
|
||||
*/
|
||||
function getPrerenderEntryFileName(
|
||||
prerenderOutput:
|
||||
| vite.Rollup.RollupOutput
|
||||
| vite.Rollup.RollupOutput[]
|
||||
| vite.Rollup.RollupWatcher,
|
||||
): string {
|
||||
const outputs = viteBuildReturnToRollupOutputs(prerenderOutput as any);
|
||||
|
||||
for (const output of outputs) {
|
||||
for (const chunk of output.output) {
|
||||
if (chunk.type !== 'asset' && 'fileName' in chunk) {
|
||||
const fileName = chunk.fileName;
|
||||
if (fileName.startsWith(PRERENDER_ENTRY_FILENAME_PREFIX)) {
|
||||
return fileName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const { lastVitePlugins, vitePlugins } = await container.runBeforeHook('client', input);
|
||||
opts.logger.info('SKIP_FORMAT', `\n${colors.bgGreen(colors.black(' building client (vite) '))}`);
|
||||
|
||||
const viteBuildConfig: vite.InlineConfig = {
|
||||
...viteConfig,
|
||||
build: {
|
||||
target: 'esnext',
|
||||
...viteConfig.build,
|
||||
emptyOutDir: false,
|
||||
outDir: fileURLToPath(out),
|
||||
copyPublicDir: ssr,
|
||||
rollupOptions: {
|
||||
...viteConfig.build?.rollupOptions,
|
||||
input: Array.from(input),
|
||||
output: {
|
||||
format: 'esm',
|
||||
entryFileNames: `${settings.config.build.assets}/[name].[hash].js`,
|
||||
chunkFileNames: `${settings.config.build.assets}/[name].[hash].js`,
|
||||
assetFileNames: `${settings.config.build.assets}/[name].[hash][extname]`,
|
||||
...viteConfig.build?.rollupOptions?.output,
|
||||
},
|
||||
preserveEntrySignatures: 'exports-only',
|
||||
},
|
||||
},
|
||||
plugins: [...vitePlugins, ...(viteConfig.plugins || []), ...lastVitePlugins],
|
||||
envPrefix: viteConfig.envPrefix ?? 'PUBLIC_',
|
||||
base: settings.config.base,
|
||||
};
|
||||
|
||||
const updatedViteBuildConfig = await runHookBuildSetup({
|
||||
config: settings.config,
|
||||
pages: internals.pagesByKeys,
|
||||
vite: viteBuildConfig,
|
||||
target: 'client',
|
||||
logger: opts.logger,
|
||||
});
|
||||
|
||||
const buildResult = await vite.build(updatedViteBuildConfig);
|
||||
return buildResult;
|
||||
throw new Error(
|
||||
'Could not find the prerender entry point in the build output. This is likely a bug in Astro.',
|
||||
);
|
||||
}
|
||||
|
||||
async function runPostBuildHooks(
|
||||
container: AstroBuildPluginContainer,
|
||||
ssrOutputs: vite.Rollup.RollupOutput[],
|
||||
clientOutputs: vite.Rollup.RollupOutput[],
|
||||
/**
|
||||
* Extracts the prerender entry filename from the build output
|
||||
* and stores it in internals for later retrieval in generatePages.
|
||||
*/
|
||||
function extractPrerenderEntryFileName(
|
||||
internals: BuildInternals,
|
||||
prerenderOutput:
|
||||
| vite.Rollup.RollupOutput
|
||||
| vite.Rollup.RollupOutput[]
|
||||
| vite.Rollup.RollupWatcher,
|
||||
) {
|
||||
const mutations = await container.runPostHook(ssrOutputs, clientOutputs);
|
||||
const config = container.options.settings.config;
|
||||
const build = container.options.settings.config.build;
|
||||
internals.prerenderEntryFileName = getPrerenderEntryFileName(prerenderOutput);
|
||||
}
|
||||
|
||||
async function runManifestInjection(
|
||||
opts: StaticBuildOptions,
|
||||
internals: BuildInternals,
|
||||
ssrOutputs: vite.Rollup.RollupOutput[],
|
||||
_clientOutputs: vite.Rollup.RollupOutput[],
|
||||
prerenderOutputs: vite.Rollup.RollupOutput[],
|
||||
) {
|
||||
const mutations = new Map<
|
||||
string,
|
||||
{
|
||||
targets: string[];
|
||||
code: string;
|
||||
}
|
||||
>();
|
||||
|
||||
const mutate: MutateChunk = (chunk, targets, newCode) => {
|
||||
chunk.code = newCode;
|
||||
mutations.set(chunk.fileName, {
|
||||
targets,
|
||||
code: newCode,
|
||||
});
|
||||
};
|
||||
|
||||
await manifestBuildPostHook(opts, internals, {
|
||||
ssrOutputs,
|
||||
prerenderOutputs,
|
||||
mutate,
|
||||
});
|
||||
|
||||
await contentAssetsBuildPostHook(opts.settings.config.base, internals, {
|
||||
ssrOutputs,
|
||||
prerenderOutputs,
|
||||
mutate,
|
||||
});
|
||||
|
||||
await writeMutatedChunks(opts, mutations, prerenderOutputs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes chunks that were modified by post-build hooks (e.g., manifest injection).
|
||||
* Mutations are collected during the manifest hook and persisted here to the
|
||||
* appropriate output directories (server, client, or prerender).
|
||||
*/
|
||||
async function writeMutatedChunks(
|
||||
opts: StaticBuildOptions,
|
||||
mutations: Map<
|
||||
string,
|
||||
{
|
||||
targets: string[];
|
||||
code: string;
|
||||
}
|
||||
>,
|
||||
prerenderOutputs: vite.Rollup.RollupOutput[],
|
||||
) {
|
||||
const { settings } = opts;
|
||||
const config = settings.config;
|
||||
const build = settings.config.build;
|
||||
const serverOutputDir = getServerOutputDirectory(settings);
|
||||
|
||||
for (const [fileName, mutation] of mutations) {
|
||||
const root =
|
||||
container.options.settings.buildOutput === 'server'
|
||||
? mutation.targets.includes('server')
|
||||
? build.server
|
||||
: build.client
|
||||
: getOutDirWithinCwd(config.outDir);
|
||||
let root: URL;
|
||||
|
||||
// Check if this is a prerender file by looking for it in prerender outputs
|
||||
const isPrerender = prerenderOutputs.some((output) =>
|
||||
output.output.some((chunk) => chunk.type !== 'asset' && (chunk as any).fileName === fileName),
|
||||
);
|
||||
|
||||
if (isPrerender) {
|
||||
// Write to prerender directory
|
||||
root = new URL('./.prerender/', serverOutputDir);
|
||||
} else if (settings.buildOutput === 'server') {
|
||||
root = mutation.targets.includes('server') ? build.server : build.client;
|
||||
} else {
|
||||
root = getOutDirWithinCwd(config.outDir);
|
||||
}
|
||||
|
||||
const fullPath = path.join(fileURLToPath(root), fileName);
|
||||
const fileURL = pathToFileURL(fullPath);
|
||||
await fs.promises.mkdir(new URL('./', fileURL), { recursive: true });
|
||||
@@ -321,88 +420,48 @@ async function runPostBuildHooks(
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove chunks that are used for prerendering only
|
||||
* Moves prerender and SSR assets to the client directory.
|
||||
* In server mode, assets are initially scattered across server and prerender
|
||||
* directories but need to be consolidated in the client directory for serving.
|
||||
*/
|
||||
async function cleanStaticOutput(opts: StaticBuildOptions, internals: BuildInternals) {
|
||||
const ssr = opts.settings.buildOutput === 'server';
|
||||
const out = ssr
|
||||
? opts.settings.config.build.server
|
||||
: getOutDirWithinCwd(opts.settings.config.outDir);
|
||||
await Promise.all(
|
||||
internals.prerenderOnlyChunks.map(async (chunk) => {
|
||||
const url = new URL(chunk.fileName, out);
|
||||
try {
|
||||
// Entry chunks may be referenced by non-deleted code, so we don't actually delete it
|
||||
// but only empty its content. These chunks should never be executed in practice, but
|
||||
// it should prevent broken import paths if adapters do a secondary bundle.
|
||||
if (chunk.isEntry || chunk.isDynamicEntry) {
|
||||
await fs.promises.writeFile(
|
||||
url,
|
||||
"// Contents removed by Astro as it's used for prerendering only",
|
||||
'utf-8',
|
||||
);
|
||||
} else {
|
||||
await fs.promises.unlink(url);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort only. Sometimes some chunks may be deleted by other plugins, like pure CSS chunks,
|
||||
// so they may already not exist.
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function cleanServerOutput(
|
||||
opts: StaticBuildOptions,
|
||||
ssrOutputChunkNames: string[],
|
||||
internals: BuildInternals,
|
||||
) {
|
||||
const out = getOutDirWithinCwd(opts.settings.config.outDir);
|
||||
// The SSR output chunks for Astro are all .mjs files
|
||||
const files = ssrOutputChunkNames.filter((f) => f.endsWith('.mjs'));
|
||||
if (internals.manifestFileName) {
|
||||
files.push(internals.manifestFileName);
|
||||
}
|
||||
if (files.length) {
|
||||
// Remove all the SSR generated .mjs files
|
||||
await Promise.all(
|
||||
files.map(async (filename) => {
|
||||
const url = new URL(filename, out);
|
||||
const map = new URL(url + '.map');
|
||||
// Sourcemaps may not be generated, so ignore any errors if fail to remove it
|
||||
await Promise.all([fs.promises.rm(url), fs.promises.rm(map).catch(() => {})]);
|
||||
}),
|
||||
);
|
||||
|
||||
removeEmptyDirs(fileURLToPath(out));
|
||||
}
|
||||
|
||||
// Clean out directly if the outDir is outside of root
|
||||
if (out.toString() !== opts.settings.config.outDir.toString()) {
|
||||
// Remove .d.ts files
|
||||
const fileNames = await fs.promises.readdir(out);
|
||||
await Promise.all(
|
||||
fileNames
|
||||
.filter((fileName) => fileName.endsWith('.d.ts'))
|
||||
.map((fileName) => fs.promises.rm(new URL(fileName, out))),
|
||||
);
|
||||
// Copy assets before cleaning directory if outside root
|
||||
await fs.promises.cp(out, opts.settings.config.outDir, { recursive: true, force: true });
|
||||
await fs.promises.rm(out, { recursive: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function ssrMoveAssets(opts: StaticBuildOptions) {
|
||||
async function ssrMoveAssets(opts: StaticBuildOptions, prerenderOutputDir: URL) {
|
||||
opts.logger.info('build', 'Rearranging server assets...');
|
||||
const serverRoot =
|
||||
opts.settings.buildOutput === 'static'
|
||||
? opts.settings.config.build.client
|
||||
: opts.settings.config.build.server;
|
||||
const clientRoot = opts.settings.config.build.client;
|
||||
const isFullyStaticSite = opts.settings.buildOutput === 'static';
|
||||
const serverRoot = opts.settings.config.build.server;
|
||||
const clientRoot = isFullyStaticSite
|
||||
? opts.settings.config.outDir
|
||||
: opts.settings.config.build.client;
|
||||
const assets = opts.settings.config.build.assets;
|
||||
const serverAssets = new URL(`./${assets}/`, appendForwardSlash(serverRoot.toString()));
|
||||
const clientAssets = new URL(`./${assets}/`, appendForwardSlash(clientRoot.toString()));
|
||||
const prerenderAssets = new URL(
|
||||
`./${assets}/`,
|
||||
appendForwardSlash(prerenderOutputDir.toString()),
|
||||
);
|
||||
|
||||
// Move prerender assets first
|
||||
const prerenderFiles = await glob(`**/*`, {
|
||||
cwd: fileURLToPath(prerenderAssets),
|
||||
});
|
||||
|
||||
if (prerenderFiles.length > 0) {
|
||||
await Promise.all(
|
||||
prerenderFiles.map(async function moveAsset(filename) {
|
||||
const currentUrl = new URL(filename, appendForwardSlash(prerenderAssets.toString()));
|
||||
const clientUrl = new URL(filename, appendForwardSlash(clientAssets.toString()));
|
||||
const dir = new URL(path.parse(clientUrl.href).dir);
|
||||
if (!fs.existsSync(dir)) await fs.promises.mkdir(dir, { recursive: true });
|
||||
return fs.promises.rename(currentUrl, clientUrl);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// If this is fully static site, we don't need to do the next parts at all.
|
||||
if (isFullyStaticSite) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Move SSR assets
|
||||
const files = await glob(`**/*`, {
|
||||
cwd: fileURLToPath(serverAssets),
|
||||
});
|
||||
@@ -423,6 +482,28 @@ async function ssrMoveAssets(opts: StaticBuildOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
function getClientInput(
|
||||
internals: BuildInternals,
|
||||
settings: StaticBuildOptions['settings'],
|
||||
): Set<string> {
|
||||
const rendererClientEntrypoints = settings.renderers
|
||||
.map((r) => r.clientEntrypoint)
|
||||
.filter((a) => typeof a === 'string') as string[];
|
||||
|
||||
const clientInput = new Set([
|
||||
...internals.discoveredHydratedComponents.keys(),
|
||||
...internals.discoveredClientOnlyComponents.keys(),
|
||||
...rendererClientEntrypoints,
|
||||
...internals.discoveredScripts,
|
||||
]);
|
||||
|
||||
if (settings.scripts.some((script) => script.stage === 'page')) {
|
||||
clientInput.add(PAGE_SCRIPT_ID);
|
||||
}
|
||||
|
||||
return clientInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function takes the virtual module name of any page entrypoint and
|
||||
* transforms it to generate a final `.mjs` output file.
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { InlineConfig } from 'vite';
|
||||
import type { AstroSettings, ComponentInstance, RoutesList } from '../../types/astro.js';
|
||||
import type { MiddlewareHandler } from '../../types/public/common.js';
|
||||
import type { RuntimeMode } from '../../types/public/config.js';
|
||||
import type { RouteData, SSRLoadedRenderer } from '../../types/public/internal.js';
|
||||
import type { RouteData } from '../../types/public/internal.js';
|
||||
import type { Logger } from '../logger/core.js';
|
||||
|
||||
type ComponentPath = string;
|
||||
@@ -46,7 +46,6 @@ export interface SinglePageBuiltModule {
|
||||
* The `onRequest` hook exported by the middleware
|
||||
*/
|
||||
onRequest?: MiddlewareHandler;
|
||||
renderers: SSRLoadedRenderer[];
|
||||
}
|
||||
|
||||
export type ViteBuildReturn = Awaited<ReturnType<typeof vite.build>>;
|
||||
|
||||
@@ -31,15 +31,6 @@ export function shouldAppendForwardSlash(
|
||||
}
|
||||
}
|
||||
|
||||
export function i18nHasFallback(config: AstroConfig): boolean {
|
||||
if (config.i18n && config.i18n.fallback) {
|
||||
// we have some fallback and the control is not none
|
||||
return Object.keys(config.i18n.fallback).length > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function encodeName(name: string): string {
|
||||
// Detect if the chunk name has as % sign that is not encoded.
|
||||
// This is borrowed from Node core: https://github.com/nodejs/node/blob/3838b579e44bf0c2db43171c3ce0da51eb6b05d5/lib/internal/url.js#L1382-L1391
|
||||
|
||||
@@ -2,7 +2,6 @@ import { fileURLToPath } from 'node:url';
|
||||
import type { TransformResult } from '@astrojs/compiler';
|
||||
import { transform } from '@astrojs/compiler';
|
||||
import type { ResolvedConfig } from 'vite';
|
||||
import type { AstroPreferences } from '../../preferences/index.js';
|
||||
import type { AstroConfig } from '../../types/public/config.js';
|
||||
import type { AstroError } from '../errors/errors.js';
|
||||
import { AggregateError, CompilerError } from '../errors/errors.js';
|
||||
@@ -14,7 +13,7 @@ import type { CompileCssResult } from './types.js';
|
||||
export interface CompileProps {
|
||||
astroConfig: AstroConfig;
|
||||
viteConfig: ResolvedConfig;
|
||||
preferences: AstroPreferences;
|
||||
toolbarEnabled: boolean;
|
||||
filename: string;
|
||||
source: string;
|
||||
}
|
||||
@@ -26,7 +25,7 @@ export interface CompileResult extends Omit<TransformResult, 'css'> {
|
||||
export async function compile({
|
||||
astroConfig,
|
||||
viteConfig,
|
||||
preferences,
|
||||
toolbarEnabled,
|
||||
filename,
|
||||
source,
|
||||
}: CompileProps): Promise<CompileResult> {
|
||||
@@ -56,7 +55,7 @@ export async function compile({
|
||||
viteConfig.command === 'serve' &&
|
||||
astroConfig.devToolbar &&
|
||||
astroConfig.devToolbar.enabled &&
|
||||
(await preferences.get('devToolbar.enabled')),
|
||||
toolbarEnabled,
|
||||
preprocessStyle: createStylePreprocessor({
|
||||
filename,
|
||||
viteConfig,
|
||||
|
||||
@@ -3,7 +3,6 @@ export {
|
||||
resolveConfigPath,
|
||||
resolveRoot,
|
||||
} from './config.js';
|
||||
export { createNodeLogger } from './logging.js';
|
||||
export { mergeConfig } from './merge.js';
|
||||
export { createSettings } from './settings.js';
|
||||
export { loadTSConfig, updateTSConfigForFramework } from './tsconfig.js';
|
||||
|
||||
@@ -5,7 +5,7 @@ import toml from 'smol-toml';
|
||||
import { getContentPaths } from '../../content/index.js';
|
||||
import createPreferences from '../../preferences/index.js';
|
||||
import type { AstroSettings } from '../../types/astro.js';
|
||||
import type { AstroConfig } from '../../types/public/config.js';
|
||||
import type { AstroConfig, AstroInlineConfig } from '../../types/public/config.js';
|
||||
import { markdownContentEntryType } from '../../vite-plugin-markdown/content-entry-type.js';
|
||||
import { getDefaultClientDirectives } from '../client-directive/index.js';
|
||||
import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from './../constants.js';
|
||||
@@ -19,7 +19,10 @@ import {
|
||||
import { AstroTimer } from './timer.js';
|
||||
import { loadTSConfig } from './tsconfig.js';
|
||||
|
||||
export function createBaseSettings(config: AstroConfig): AstroSettings {
|
||||
export function createBaseSettings(
|
||||
config: AstroConfig,
|
||||
logLevel: AstroInlineConfig['logLevel'],
|
||||
): AstroSettings {
|
||||
const { contentDir } = getContentPaths(config);
|
||||
const dotAstroDir = new URL('.astro/', config.root);
|
||||
const preferences = createPreferences(config, dotAstroDir);
|
||||
@@ -31,8 +34,6 @@ export function createBaseSettings(config: AstroConfig): AstroSettings {
|
||||
adapter: undefined,
|
||||
injectedRoutes: [],
|
||||
resolvedInjectedRoutes: [],
|
||||
serverIslandMap: new Map(),
|
||||
serverIslandNameMap: new Map(),
|
||||
pageExtensions: ['.astro', '.html', ...SUPPORTED_MARKDOWN_FILE_EXTENSIONS],
|
||||
contentEntryTypes: [markdownContentEntryType],
|
||||
dataEntryTypes: [
|
||||
@@ -154,12 +155,17 @@ export function createBaseSettings(config: AstroConfig): AstroSettings {
|
||||
fontResources: new Set(),
|
||||
styleHashes: [],
|
||||
},
|
||||
logLevel: logLevel ?? 'info',
|
||||
};
|
||||
}
|
||||
|
||||
export async function createSettings(config: AstroConfig, cwd?: string): Promise<AstroSettings> {
|
||||
export async function createSettings(
|
||||
config: AstroConfig,
|
||||
logLevel: AstroInlineConfig['logLevel'],
|
||||
cwd?: string,
|
||||
): Promise<AstroSettings> {
|
||||
const tsconfig = await loadTSConfig(cwd);
|
||||
const settings = createBaseSettings(config);
|
||||
const settings = createBaseSettings(config, logLevel);
|
||||
|
||||
let watchFiles = [];
|
||||
if (cwd) {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import type fsType from 'node:fs';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { createServer, type ViteDevServer } from 'vite';
|
||||
import {
|
||||
createServer,
|
||||
isRunnableDevEnvironment,
|
||||
type RunnableDevEnvironment,
|
||||
type ViteDevServer,
|
||||
} from 'vite';
|
||||
import loadFallbackPlugin from '../../vite-plugin-load-fallback/index.js';
|
||||
import { debug } from '../logger/core.js';
|
||||
import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../constants.js';
|
||||
|
||||
async function createViteServer(root: string, fs: typeof fsType): Promise<ViteDevServer> {
|
||||
const viteServer = await createServer({
|
||||
@@ -50,8 +56,15 @@ export async function loadConfigWithVite({
|
||||
let server: ViteDevServer | undefined;
|
||||
try {
|
||||
server = await createViteServer(root, fs);
|
||||
const mod = await server.ssrLoadModule(configPath, { fixStacktrace: true });
|
||||
return mod.default ?? {};
|
||||
if (isRunnableDevEnvironment(server.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr])) {
|
||||
const environment = server.environments[
|
||||
ASTRO_VITE_ENVIRONMENT_NAMES.ssr
|
||||
] as RunnableDevEnvironment;
|
||||
const mod = await environment.runner.import(configPath);
|
||||
return mod.default ?? {};
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
} finally {
|
||||
if (server) {
|
||||
await server.close();
|
||||
|
||||
@@ -104,3 +104,24 @@ export const SUPPORTED_MARKDOWN_FILE_EXTENSIONS = [
|
||||
|
||||
// The folder name where to find the middleware
|
||||
export const MIDDLEWARE_PATH_SEGMENT_NAME = 'middleware';
|
||||
|
||||
// The environments used inside Astro
|
||||
export const ASTRO_VITE_ENVIRONMENT_NAMES = {
|
||||
// It maps to the classic `ssr` Vite environment
|
||||
ssr: 'ssr',
|
||||
// It maps to the classic `client` Vite environment
|
||||
client: 'client',
|
||||
// Use this environment when `ssr` isn't a runnable dev environment, and you need
|
||||
// a runnable dev environment. A runnable dev environment allows you, for example,
|
||||
// to load a module via `runner.import`.
|
||||
//
|
||||
// This environment should be used only for dev, not production.
|
||||
astro: 'astro',
|
||||
// Environment used during the build for rendering static pages.
|
||||
// If your plugin runs in `ASTRO_VITE_ENVIRONMENT_NAMES.ssr`, you might
|
||||
// want to add `ASTRO_VITE_ENVIRONMENT_NAMES.prerender` too
|
||||
prerender: 'prerender',
|
||||
} as const;
|
||||
|
||||
export type AstroEnvironmentNames =
|
||||
(typeof ASTRO_VITE_ENVIRONMENT_NAMES)[keyof typeof ASTRO_VITE_ENVIRONMENT_NAMES];
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import nodeFs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { convertPathToPattern } from 'tinyglobby';
|
||||
import * as vite from 'vite';
|
||||
import { crawlFrameworkPkgs } from 'vitefu';
|
||||
import { vitePluginActions } from '../actions/vite-plugin-actions.js';
|
||||
@@ -16,15 +15,21 @@ import { createEnvLoader } from '../env/env-loader.js';
|
||||
import { astroEnv } from '../env/vite-plugin-env.js';
|
||||
import { importMetaEnv } from '../env/vite-plugin-import-meta-env.js';
|
||||
import astroInternationalization from '../i18n/vite-plugin-i18n.js';
|
||||
import { serializedManifestPlugin } from '../manifest/serialized.js';
|
||||
import astroVirtualManifestPlugin from '../manifest/virtual-module.js';
|
||||
import astroPrefetch from '../prefetch/vite-plugin-prefetch.js';
|
||||
import astroDevToolbar from '../toolbar/vite-plugin-dev-toolbar.js';
|
||||
import astroTransitions from '../transitions/vite-plugin-transitions.js';
|
||||
import type { AstroSettings, RoutesList } from '../types/astro.js';
|
||||
import { vitePluginAdapterConfig } from '../vite-plugin-adapter-config/index.js';
|
||||
import { vitePluginApp } from '../vite-plugin-app/index.js';
|
||||
import astroVitePlugin from '../vite-plugin-astro/index.js';
|
||||
import { vitePluginAstroServer } from '../vite-plugin-astro-server/index.js';
|
||||
import {
|
||||
vitePluginAstroServer,
|
||||
vitePluginAstroServerClient,
|
||||
} from '../vite-plugin-astro-server/index.js';
|
||||
import configAliasVitePlugin from '../vite-plugin-config-alias/index.js';
|
||||
import { astroDevCssPlugin } from '../vite-plugin-css/index.js';
|
||||
import vitePluginFileURL from '../vite-plugin-fileurl/index.js';
|
||||
import astroHeadPlugin from '../vite-plugin-head/index.js';
|
||||
import astroHmrReloadPlugin from '../vite-plugin-hmr-reload/index.js';
|
||||
@@ -32,65 +37,41 @@ import htmlVitePlugin from '../vite-plugin-html/index.js';
|
||||
import astroIntegrationsContainerPlugin from '../vite-plugin-integrations-container/index.js';
|
||||
import astroLoadFallbackPlugin from '../vite-plugin-load-fallback/index.js';
|
||||
import markdownVitePlugin from '../vite-plugin-markdown/index.js';
|
||||
import astroScannerPlugin from '../vite-plugin-scanner/index.js';
|
||||
import { pluginPage, pluginPages } from '../vite-plugin-pages/index.js';
|
||||
import vitePluginRenderers from '../vite-plugin-renderers/index.js';
|
||||
import astroPluginRoutes from '../vite-plugin-routes/index.js';
|
||||
import astroScriptsPlugin from '../vite-plugin-scripts/index.js';
|
||||
import astroScriptsPageSSRPlugin from '../vite-plugin-scripts/page-ssr.js';
|
||||
import { vitePluginSSRManifest } from '../vite-plugin-ssr-manifest/index.js';
|
||||
import type { SSRManifest } from './app/types.js';
|
||||
import type { Logger } from './logger/core.js';
|
||||
import { createViteLogger } from './logger/vite.js';
|
||||
import { vitePluginMiddleware } from './middleware/vite-plugin.js';
|
||||
import { joinPaths } from './path.js';
|
||||
import { vitePluginServerIslands } from './server-islands/vite-plugin-server-islands.js';
|
||||
import { vitePluginSessionDriver } from './session/vite-plugin.js';
|
||||
import { isObject } from './util.js';
|
||||
import { vitePluginEnvironment } from '../vite-plugin-environment/index.js';
|
||||
import { ASTRO_VITE_ENVIRONMENT_NAMES } from './constants.js';
|
||||
|
||||
type CreateViteOptions = {
|
||||
settings: AstroSettings;
|
||||
logger: Logger;
|
||||
mode: string;
|
||||
fs?: typeof nodeFs;
|
||||
sync: boolean;
|
||||
routesList: RoutesList;
|
||||
manifest: SSRManifest;
|
||||
sync: boolean;
|
||||
} & (
|
||||
| {
|
||||
command: 'dev';
|
||||
manifest: SSRManifest;
|
||||
}
|
||||
| {
|
||||
command: 'build';
|
||||
manifest?: SSRManifest;
|
||||
}
|
||||
);
|
||||
|
||||
const ALWAYS_NOEXTERNAL = [
|
||||
// This is only because Vite's native ESM doesn't resolve "exports" correctly.
|
||||
'astro',
|
||||
// Vite fails on nested `.astro` imports without bundling
|
||||
'astro/components',
|
||||
// Handle recommended nanostores. Only @nanostores/preact is required from our testing!
|
||||
// Full explanation and related bug report: https://github.com/withastro/astro/pull/3667
|
||||
'@nanostores/preact',
|
||||
// fontsource packages are CSS that need to be processed
|
||||
'@fontsource/*',
|
||||
];
|
||||
|
||||
// These specifiers are usually dependencies written in CJS, but loaded through Vite's transform
|
||||
// pipeline, which Vite doesn't support in development time. This hardcoded list temporarily
|
||||
// fixes things until Vite can properly handle them, or when they support ESM.
|
||||
const ONLY_DEV_EXTERNAL = [
|
||||
// Imported by `@astrojs/prism` which exposes `<Prism/>` that is processed by Vite
|
||||
'prismjs/components/index.js',
|
||||
// Imported by `astro/assets` -> `packages/astro/src/core/logger/core.ts`
|
||||
'string-width',
|
||||
// Imported by `astro:transitions` -> packages/astro/src/runtime/server/transition.ts
|
||||
'cssesc',
|
||||
];
|
||||
|
||||
/** Return a base vite config as a common starting point for all Vite commands. */
|
||||
export async function createVite(
|
||||
commandConfig: vite.InlineConfig,
|
||||
{ settings, logger, mode, command, fs = nodeFs, sync, routesList, manifest }: CreateViteOptions,
|
||||
{ settings, logger, mode, command, fs = nodeFs, sync, routesList }: CreateViteOptions,
|
||||
): Promise<vite.InlineConfig> {
|
||||
const astroPkgsConfig = await crawlFrameworkPkgs({
|
||||
root: fileURLToPath(settings.config.root),
|
||||
@@ -124,7 +105,6 @@ export async function createVite(
|
||||
},
|
||||
});
|
||||
|
||||
const srcDirPattern = convertPathToPattern(fileURLToPath(settings.config.srcDir));
|
||||
const envLoader = createEnvLoader({
|
||||
mode,
|
||||
config: settings.config,
|
||||
@@ -139,20 +119,24 @@ export async function createVite(
|
||||
clearScreen: false, // we want to control the output, not Vite
|
||||
customLogger: createViteLogger(logger, settings.config.vite.logLevel),
|
||||
appType: 'custom',
|
||||
optimizeDeps: {
|
||||
// Scan for component code within `srcDir`
|
||||
entries: [`${srcDirPattern}**/*.{jsx,tsx,vue,svelte,html,astro}`],
|
||||
exclude: ['astro', 'node-fetch'],
|
||||
},
|
||||
plugins: [
|
||||
astroVirtualManifestPlugin({ manifest }),
|
||||
serializedManifestPlugin({ settings, command, sync }),
|
||||
vitePluginRenderers({ settings }),
|
||||
await astroPluginRoutes({ routesList, settings, logger, fsMod: fs }),
|
||||
astroVirtualManifestPlugin(),
|
||||
vitePluginEnvironment({ settings, astroPkgsConfig, command }),
|
||||
pluginPage({ routesList }),
|
||||
pluginPages({ routesList }),
|
||||
configAliasVitePlugin({ settings }),
|
||||
astroLoadFallbackPlugin({ fs, root: settings.config.root }),
|
||||
astroVitePlugin({ settings, logger }),
|
||||
astroScriptsPlugin({ settings }),
|
||||
// The server plugin is for dev only and having it run during the build causes
|
||||
// the build to run very slow as the filewatcher is triggered often.
|
||||
command === 'dev' && vitePluginAstroServer({ settings, logger, fs, routesList, manifest }), // manifest is only required in dev mode, where it gets created before a Vite instance is created, and get passed to this function
|
||||
command === 'dev' && vitePluginApp(),
|
||||
command === 'dev' && vitePluginAstroServer({ settings, logger }),
|
||||
command === 'dev' && vitePluginAstroServerClient(),
|
||||
astroDevCssPlugin({ routesList, command }),
|
||||
importMetaEnv({ envLoader }),
|
||||
astroEnv({ settings, sync, envLoader }),
|
||||
vitePluginAdapterConfig(settings),
|
||||
@@ -161,12 +145,10 @@ export async function createVite(
|
||||
astroIntegrationsContainerPlugin({ settings, logger }),
|
||||
astroScriptsPageSSRPlugin({ settings }),
|
||||
astroHeadPlugin(),
|
||||
astroScannerPlugin({ settings, logger, routesList }),
|
||||
astroContentVirtualModPlugin({ fs, settings }),
|
||||
astroContentImportPlugin({ fs, settings, logger }),
|
||||
astroContentAssetPropagationPlugin({ settings }),
|
||||
vitePluginMiddleware({ settings }),
|
||||
vitePluginSSRManifest(),
|
||||
astroAssetsPlugin({ fs, settings, sync, logger }),
|
||||
astroPrefetch({ settings }),
|
||||
astroTransitions({ settings }),
|
||||
@@ -175,6 +157,7 @@ export async function createVite(
|
||||
astroInternationalization({ settings }),
|
||||
vitePluginActions({ fs, settings }),
|
||||
vitePluginServerIslands({ settings, logger }),
|
||||
vitePluginSessionDriver({ settings }),
|
||||
astroContainer(),
|
||||
astroHmrReloadPlugin(),
|
||||
],
|
||||
@@ -223,14 +206,14 @@ export async function createVite(
|
||||
replacement: 'astro/components',
|
||||
},
|
||||
],
|
||||
// Astro imports in third-party packages should use the same version as root
|
||||
dedupe: ['astro'],
|
||||
},
|
||||
ssr: {
|
||||
noExternal: [...ALWAYS_NOEXTERNAL, ...astroPkgsConfig.ssr.noExternal],
|
||||
external: [...(command === 'dev' ? ONLY_DEV_EXTERNAL : []), ...astroPkgsConfig.ssr.external],
|
||||
},
|
||||
build: { assetsDir: settings.config.build.assets },
|
||||
environments: {
|
||||
[ASTRO_VITE_ENVIRONMENT_NAMES.astro]: {
|
||||
// This is all that's needed to create a new RunnableDevEnvironment
|
||||
dev: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// If the user provides a custom assets prefix, make sure assets handled by Vite
|
||||
|
||||
@@ -55,10 +55,10 @@ export function getStyleResources(csp: EnabledCsp): string[] {
|
||||
// because it has to collect and deduplicate font resources from both the user
|
||||
// config and the vite plugin for fonts
|
||||
export function getDirectives(settings: AstroSettings): CspDirective[] {
|
||||
const { csp } = settings.config.experimental;
|
||||
if (!shouldTrackCspHashes(csp)) {
|
||||
if (!shouldTrackCspHashes(settings.config.experimental.csp)) {
|
||||
return [];
|
||||
}
|
||||
const { csp } = settings.config.experimental;
|
||||
const userDirectives = csp === true ? [] : [...(csp.directives ?? [])];
|
||||
const fontResources = Array.from(settings.injectedCsp.fontResources.values());
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from '../../integrations/hooks.js';
|
||||
import type { AstroSettings } from '../../types/astro.js';
|
||||
import type { AstroInlineConfig } from '../../types/public/config.js';
|
||||
import { createDevelopmentManifest } from '../../vite-plugin-astro-server/plugin.js';
|
||||
import { createVite } from '../create-vite.js';
|
||||
import type { Logger } from '../logger/core.js';
|
||||
import { createRoutesList } from '../routing/index.js';
|
||||
@@ -79,14 +78,23 @@ export async function createContainer({
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
// Create the route manifest already outside of Vite so that `runHookConfigDone` can use it to inform integrations of the build output
|
||||
const routesList = await createRoutesList({ settings, fsMod: fs }, logger, { dev: true });
|
||||
const manifest = createDevelopmentManifest(settings);
|
||||
|
||||
await runHookConfigDone({ settings, logger, command: 'dev' });
|
||||
|
||||
warnMissingAdapter(logger, settings);
|
||||
|
||||
const mode = inlineConfig?.mode ?? 'development';
|
||||
const initialRoutesList = await createRoutesList(
|
||||
{
|
||||
settings,
|
||||
fsMod: nodeFs,
|
||||
},
|
||||
logger,
|
||||
{
|
||||
dev: true,
|
||||
// If the adapter explicitly set a buildOutput, don't override it
|
||||
skipBuildOutputAssignment: !!settings.adapter?.adapterFeatures?.buildOutput
|
||||
},
|
||||
);
|
||||
const viteConfig = await createVite(
|
||||
{
|
||||
server: { host, headers, open, allowedHosts },
|
||||
@@ -101,8 +109,7 @@ export async function createContainer({
|
||||
command: 'dev',
|
||||
fs,
|
||||
sync: false,
|
||||
routesList,
|
||||
manifest,
|
||||
routesList: initialRoutesList,
|
||||
},
|
||||
);
|
||||
const viteServer = await vite.createServer(viteConfig);
|
||||
@@ -116,8 +123,6 @@ export async function createContainer({
|
||||
cleanup: true,
|
||||
},
|
||||
force: inlineConfig?.force,
|
||||
routesList,
|
||||
manifest,
|
||||
command: 'dev',
|
||||
watcher: viteServer.watcher,
|
||||
});
|
||||
|
||||
@@ -95,7 +95,6 @@ export default async function dev(inlineConfig: AstroInlineConfig): Promise<DevS
|
||||
if (!store) {
|
||||
logger.error('content', 'Failed to create data store');
|
||||
}
|
||||
|
||||
await attachContentServerListeners(restart.container);
|
||||
|
||||
const config = globalContentConfigObserver.get();
|
||||
|
||||
@@ -7,7 +7,8 @@ import { eventCliSession, telemetry } from '../../events/index.js';
|
||||
import { SETTINGS_FILE } from '../../preferences/constants.js';
|
||||
import type { AstroSettings } from '../../types/astro.js';
|
||||
import type { AstroInlineConfig } from '../../types/public/config.js';
|
||||
import { createNodeLogger, createSettings, resolveConfig } from '../config/index.js';
|
||||
import { createSettings, resolveConfig } from '../config/index.js';
|
||||
import { createNodeLogger } from '../config/logging.js';
|
||||
import { collectErrorMetadata } from '../errors/dev/utils.js';
|
||||
import { isAstroConfigZodError } from '../errors/errors.js';
|
||||
import { createSafeError } from '../errors/index.js';
|
||||
@@ -83,7 +84,11 @@ async function restartContainer(container: Container): Promise<Container | Error
|
||||
"Astro's Content Security Policy (CSP) does not work in development mode. To verify your CSP implementation, build the project and run the preview server.",
|
||||
);
|
||||
}
|
||||
const settings = await createSettings(astroConfig, fileURLToPath(existingSettings.config.root));
|
||||
const settings = await createSettings(
|
||||
astroConfig,
|
||||
container.inlineConfig.logLevel,
|
||||
fileURLToPath(existingSettings.config.root),
|
||||
);
|
||||
await close();
|
||||
return await createRestartedContainer(container, settings);
|
||||
} catch (_err) {
|
||||
@@ -96,7 +101,7 @@ async function restartContainer(container: Container): Promise<Container | Error
|
||||
);
|
||||
}
|
||||
// Inform connected clients of the config error
|
||||
container.viteServer.hot.send({
|
||||
container.viteServer.environments.client.hot.send({
|
||||
type: 'error',
|
||||
err: {
|
||||
message: error.message,
|
||||
@@ -133,7 +138,11 @@ export async function createContainerWithAutomaticRestart({
|
||||
}
|
||||
telemetry.record(eventCliSession('dev', userConfig));
|
||||
|
||||
const settings = await createSettings(astroConfig, fileURLToPath(astroConfig.root));
|
||||
const settings = await createSettings(
|
||||
astroConfig,
|
||||
inlineConfig?.logLevel,
|
||||
fileURLToPath(astroConfig.root),
|
||||
);
|
||||
|
||||
const initialContainer = await createContainer({
|
||||
settings,
|
||||
|
||||
40
packages/astro/src/core/errors/dev/runtime.ts
Normal file
40
packages/astro/src/core/errors/dev/runtime.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { escape } from 'html-escaper';
|
||||
import colors from 'piccolore';
|
||||
import { AstroErrorData, type ErrorWithMetadata } from '../index.js';
|
||||
|
||||
/**
|
||||
* The docs has kebab-case urls for errors, so we need to convert the error name
|
||||
* @param errorName
|
||||
*/
|
||||
function getKebabErrorName(errorName: string): string {
|
||||
return errorName.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
|
||||
}
|
||||
export function getDocsForError(err: ErrorWithMetadata): string | undefined {
|
||||
if (err.name !== 'UnknownError' && err.name in AstroErrorData) {
|
||||
return `https://docs.astro.build/en/reference/errors/${getKebabErrorName(err.name)}/`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const linkRegex = /\[([^[]+)\]\((.*)\)/g;
|
||||
const boldRegex = /\*\*(.+)\*\*/g;
|
||||
const urlRegex = / ((?:https?|ftp):\/\/[-\w+&@#\\/%?=~|!:,.;]*[-\w+&@#\\/%=~|])/gi;
|
||||
const codeRegex = /`([^`]+)`/g;
|
||||
|
||||
/**
|
||||
* Render a subset of Markdown to HTML or a CLI output
|
||||
*/
|
||||
export function renderErrorMarkdown(markdown: string, target: 'html' | 'cli') {
|
||||
if (target === 'html') {
|
||||
return escape(markdown)
|
||||
.replace(linkRegex, `<a href="$2" target="_blank">$1</a>`)
|
||||
.replace(boldRegex, '<b>$1</b>')
|
||||
.replace(urlRegex, ' <a href="$1" target="_blank">$1</a>')
|
||||
.replace(codeRegex, '<code>$1</code>');
|
||||
} else {
|
||||
return markdown
|
||||
.replace(linkRegex, (_, m1, m2) => `${colors.bold(m1)} ${colors.underline(m2)}`)
|
||||
.replace(urlRegex, (fullMatch) => ` ${colors.underline(fullMatch.trim())}`)
|
||||
.replace(boldRegex, (_, m1) => `${colors.bold(m1)}`);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import type { ModuleLoader } from '../../module-loader/index.js';
|
||||
import { AstroError, type ErrorWithMetadata } from '../errors.js';
|
||||
import { FailedToLoadModuleSSR, MdxIntegrationMissingError } from '../errors-data.js';
|
||||
import { createSafeError } from '../utils.js';
|
||||
import { getDocsForError, renderErrorMarkdown } from './utils.js';
|
||||
import { getDocsForError, renderErrorMarkdown } from './runtime.js';
|
||||
|
||||
export function enhanceViteSSRError({
|
||||
error,
|
||||
|
||||
@@ -1168,6 +1168,18 @@ export const MissingMiddlewareForInternationalization = {
|
||||
"Your configuration setting `i18n.routing: 'manual'` requires you to provide your own i18n `middleware` file.",
|
||||
} satisfies ErrorData;
|
||||
|
||||
/**
|
||||
* @docs
|
||||
* @description
|
||||
* An invalid i18n middleware configuration was detected.
|
||||
*/
|
||||
export const InvalidI18nMiddlewareConfiguration = {
|
||||
name: 'InvalidI18nMiddlewareConfiguration',
|
||||
title: 'Invalid internationalization middleware configuration',
|
||||
message:
|
||||
'The option `redirectToDefaultLocale` can be enabled only when `prefixDefaultLocale` is also set to `true`, otherwise redirects might cause infinite loops. Enable the option `prefixDefaultLocale` to continue to use `redirectToDefaultLocale`, or ensure both are set to `false`.',
|
||||
} satisfies ErrorData;
|
||||
|
||||
/**
|
||||
* @docs
|
||||
* @description
|
||||
|
||||
@@ -27,7 +27,7 @@ type ErrorTypes =
|
||||
| 'AggregateError';
|
||||
|
||||
export function isAstroError(e: unknown): e is AstroError {
|
||||
return e instanceof AstroError || AstroError.is(e);
|
||||
return e != null && (e instanceof AstroError || AstroError.is(e));
|
||||
}
|
||||
|
||||
export class AstroError extends Error {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user