diff options
author | 2022-02-14 12:48:52 -0500 | |
---|---|---|
committer | 2022-02-14 12:48:52 -0500 | |
commit | ba5e2b5e6c20207955991775dc4aa8879331542c (patch) | |
tree | 28e68347035a534f8b56991ede570dfcf830cb01 | |
parent | 61f438fdcbab7163bc3399e623a80d283e018371 (diff) | |
download | astro-ba5e2b5e6c20207955991775dc4aa8879331542c.tar.gz astro-ba5e2b5e6c20207955991775dc4aa8879331542c.tar.zst astro-ba5e2b5e6c20207955991775dc4aa8879331542c.zip |
Flagged SSR support (#2548)
* Checkpoint, basics are working
* Add the `--experimental-ssr` flag
* Adds the changeset
* Fixes population of getStaticPaths results
* Pass through the imported module
* Route manifest test
* Fix remaining tests
* Fix remaining tests
* Copy server assets over
* Fix types
* Allowing passing in the request to the Node version of App
* Improve the example app
* Gets CI to pass
67 files changed, 1684 insertions, 627 deletions
diff --git a/.changeset/slow-islands-fix.md b/.changeset/slow-islands-fix.md new file mode 100644 index 000000000..1da871343 --- /dev/null +++ b/.changeset/slow-islands-fix.md @@ -0,0 +1,29 @@ +--- +'astro': patch +--- + +Experimental SSR Support + +> ⚠️ If you are a user of Astro and see this PR and think that you can start deploying your app to a server and get SSR, slow down a second! This is only the initial flag and **very basic support**. Styles are not loading correctly at this point, for example. Like we did with the `--experimental-static-build` flag, this feature will be refined over the next few weeks/months and we'll let you know when its ready for community testing. + +## Changes + +- This adds a new `--experimental-ssr` flag to `astro build` which will result in `dist/server/` and `dist/client/` directories. +- SSR can be used through this API: + ```js + import { createServer } from 'http'; + import { loadApp } from 'astro/app/node'; + + const app = await loadApp(new URL('./dist/server/', import.meta.url)); + + createServer((req, res) => { + const route = app.match(req); + if(route) { + let html = await app.render(req, route); + } + + }).listen(8080); + ``` +- This API will be refined over time. +- This only works in Node.js at the moment. +- Many features will likely not work correctly, but rendering HTML at least should. diff --git a/comp.txt b/comp.txt new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/comp.txt diff --git a/examples/fast-build/package.json b/examples/fast-build/package.json index 924671aa8..21fe139d5 100644 --- a/examples/fast-build/package.json +++ b/examples/fast-build/package.json @@ -11,7 +11,7 @@ }, "devDependencies": { "astro": "^0.23.0-next.6", - "preact": "~10.5.15", + "preact": "~10.6.5", "unocss": "^0.15.5", "vite-imagetools": "^4.0.1" } diff --git a/examples/ssr/astro.config.mjs b/examples/ssr/astro.config.mjs new file mode 100644 index 000000000..7c986b97d --- /dev/null +++ b/examples/ssr/astro.config.mjs @@ -0,0 +1,12 @@ +// @ts-check + +export default /** @type {import('astro').AstroUserConfig} */ ({ + renderers: ['@astrojs/renderer-svelte'], + vite: { + server: { + proxy: { + '/api': 'http://localhost:8085' + } + } + } +}); diff --git a/examples/ssr/build.mjs b/examples/ssr/build.mjs new file mode 100644 index 000000000..5d2e4a3aa --- /dev/null +++ b/examples/ssr/build.mjs @@ -0,0 +1,12 @@ +import {execa} from 'execa'; + +const api = execa('npm', ['run', 'dev-api']); +api.stdout.pipe(process.stdout); +api.stderr.pipe(process.stderr); + +const build = execa('yarn', ['astro', 'build', '--experimental-ssr']); +build.stdout.pipe(process.stdout); +build.stderr.pipe(process.stderr); +await build; + +api.kill(); diff --git a/examples/ssr/package.json b/examples/ssr/package.json new file mode 100644 index 000000000..c783a5416 --- /dev/null +++ b/examples/ssr/package.json @@ -0,0 +1,21 @@ +{ + "name": "@example/ssr", + "version": "0.0.1", + "private": true, + "scripts": { + "dev-api": "node server/dev-api.mjs", + "dev": "npm run dev-api & astro dev --experimental-ssr", + "start": "astro dev", + "build": "echo 'Run yarn build-ssr instead'", + "build-ssr": "node build.mjs", + "server": "node server/server.mjs" + }, + "devDependencies": { + "astro": "^0.23.0-next.0", + "unocss": "^0.15.5", + "vite-imagetools": "^4.0.1" + }, + "dependencies": { + "@astropub/webapi": "^0.10.13" + } +} diff --git a/examples/ssr/public/images/products/cereal.jpg b/examples/ssr/public/images/products/cereal.jpg Binary files differnew file mode 100644 index 000000000..c1f4cce4a --- /dev/null +++ b/examples/ssr/public/images/products/cereal.jpg diff --git a/examples/ssr/public/images/products/muffins.jpg b/examples/ssr/public/images/products/muffins.jpg Binary files differnew file mode 100644 index 000000000..897733ee8 --- /dev/null +++ b/examples/ssr/public/images/products/muffins.jpg diff --git a/examples/ssr/public/images/products/oats.jpg b/examples/ssr/public/images/products/oats.jpg Binary files differnew file mode 100644 index 000000000..b8db72ae0 --- /dev/null +++ b/examples/ssr/public/images/products/oats.jpg diff --git a/examples/ssr/public/images/products/yogurt.jpg b/examples/ssr/public/images/products/yogurt.jpg Binary files differnew file mode 100644 index 000000000..9cd39666d --- /dev/null +++ b/examples/ssr/public/images/products/yogurt.jpg diff --git a/examples/ssr/server/api.mjs b/examples/ssr/server/api.mjs new file mode 100644 index 000000000..3928d0507 --- /dev/null +++ b/examples/ssr/server/api.mjs @@ -0,0 +1,49 @@ +import fs from 'fs'; +const dbJSON = fs.readFileSync(new URL('./db.json', import.meta.url)); +const db = JSON.parse(dbJSON); +const products = db.products; +const productMap = new Map(products.map(product => [product.id, product])); + +const routes = [ + { + match: /\/api\/products\/([0-9])+/, + async handle(_req, res, [,idStr]) { + const id = Number(idStr); + if(productMap.has(id)) { + const product = productMap.get(id); + res.writeHead(200, { + 'Content-Type': 'application/json' + }); + res.end(JSON.stringify(product)); + } else { + res.writeHead(404, { + 'Content-Type': 'text/plain' + }); + res.end('Not found'); + } + } + }, + { + match: /\/api\/products/, + async handle(_req, res) { + res.writeHead(200, { + 'Content-Type': 'application/json', + }); + res.end(JSON.stringify(products)); + } + } + +] + +export async function apiHandler(req, res) { + for(const route of routes) { + const match = route.match.exec(req.url); + if(match) { + return route.handle(req, res, match); + } + } + res.writeHead(404, { + 'Content-Type': 'text/plain' + }); + res.end('Not found'); +} diff --git a/examples/ssr/server/db.json b/examples/ssr/server/db.json new file mode 100644 index 000000000..76f9e4da3 --- /dev/null +++ b/examples/ssr/server/db.json @@ -0,0 +1,28 @@ +{ + "products": [ + { + "id": 1, + "name": "Cereal", + "price": 3.99, + "image": "/images/products/cereal.jpg" + }, + { + "id": 2, + "name": "Yogurt", + "price": 3.97, + "image": "/images/products/yogurt.jpg" + }, + { + "id": 3, + "name": "Rolled Oats", + "price": 2.89, + "image": "/images/products/oats.jpg" + }, + { + "id": 4, + "name": "Muffins", + "price": 4.39, + "image": "/images/products/muffins.jpg" + } + ] +} diff --git a/examples/ssr/server/dev-api.mjs b/examples/ssr/server/dev-api.mjs new file mode 100644 index 000000000..74e0ef83b --- /dev/null +++ b/examples/ssr/server/dev-api.mjs @@ -0,0 +1,17 @@ +import { createServer } from 'http'; +import { apiHandler } from './api.mjs'; + +const PORT = process.env.PORT || 8085; + +const server = createServer((req, res) => { + apiHandler(req, res).catch(err => { + console.error(err); + res.writeHead(500, { + 'Content-Type': 'text/plain' + }); + res.end(err.toString()); + }) +}); + +server.listen(PORT); +console.log(`API running at http://localhost:${PORT}`); diff --git a/examples/ssr/server/server.mjs b/examples/ssr/server/server.mjs new file mode 100644 index 000000000..6f0a0dea6 --- /dev/null +++ b/examples/ssr/server/server.mjs @@ -0,0 +1,55 @@ +import { createServer } from 'http'; +import fs from 'fs'; +import mime from 'mime'; +import { loadApp } from 'astro/app/node'; +import { polyfill } from '@astropub/webapi' +import { apiHandler } from './api.mjs'; + +polyfill(globalThis); + +const clientRoot = new URL('../dist/client/', import.meta.url); +const serverRoot = new URL('../dist/server/', import.meta.url); +const app = await loadApp(serverRoot); + +async function handle(req, res) { + const route = app.match(req); + + if(route) { + const html = await app.render(req, route); + + res.writeHead(200, { + 'Content-Type': 'text/html' + }); + res.end(html) + } else if(/^\/api\//.test(req.url)) { + return apiHandler(req, res); + } else { + let local = new URL('.' + req.url, clientRoot); + try { + const data = await fs.promises.readFile(local); + res.writeHead(200, { + 'Content-Type': mime.getType(req.url) + }); + res.end(data); + } catch { + res.writeHead(404); + res.end(); + } + } +} + +const server = createServer((req, res) => { + handle(req, res).catch(err => { + console.error(err); + res.writeHead(500, { + 'Content-Type': 'text/plain' + }); + res.end(err.toString()); + }) +}); + +server.listen(8085); +console.log('Serving at http://localhost:8085'); + +// Silence weird <time> warning +console.error = () => {}; diff --git a/examples/ssr/src/api.ts b/examples/ssr/src/api.ts new file mode 100644 index 000000000..9fd7d0683 --- /dev/null +++ b/examples/ssr/src/api.ts @@ -0,0 +1,35 @@ +interface Product { + id: number; + name: string; + price: number; + image: string; +} + +//let origin: string; +const { mode } = import.meta.env; +const origin = mode === 'develeopment' ? + `http://localhost:3000` : + `http://localhost:8085`; + +async function get<T>(endpoint: string, cb: (response: Response) => Promise<T>): Promise<T> { + const response = await fetch(`${origin}${endpoint}`); + if(!response.ok) { + // TODO make this better... + return null; + } + return cb(response); +} + +export async function getProducts(): Promise<Product[]> { + return get<Product[]>('/api/products', async response => { + const products: Product[] = await response.json(); + return products; + }); +} + +export async function getProduct(id: number): Promise<Product> { + return get<Product>(`/api/products/${id}`, async response => { + const product: Product = await response.json(); + return product; + }); +} diff --git a/examples/ssr/src/components/AddToCart.svelte b/examples/ssr/src/components/AddToCart.svelte new file mode 100644 index 000000000..b03b8180a --- /dev/null +++ b/examples/ssr/src/components/AddToCart.svelte @@ -0,0 +1,47 @@ +<script> + export let id = 0; + + function addToCart() { + window.dispatchEvent(new CustomEvent('add-to-cart', { + detail: id + })); + } +</script> +<style> + button { + display:block; + padding:0.5em 1em 0.5em 1em; + border-radius:100px; + border:none; + font-size: 1.4em; + position:relative; + background:#0652DD; + cursor:pointer; + height:2em; + width:10em; + overflow:hidden; + transition:transform 0.1s; + z-index:1; +} +button:hover { + transform:scale(1.1); +} + +.pretext { + color:#fff; + background:#0652DD; + position:absolute; + top:0; + left:0; + height:100%; + width:100%; + display:flex; + justify-content:center; + align-items:center; + font-family: 'Quicksand', sans-serif; + text-transform: uppercase; +} +</style> +<button on:click={addToCart}> + <span class="pretext">Add to cart</span> +</button> diff --git a/examples/ssr/src/components/Cart.svelte b/examples/ssr/src/components/Cart.svelte new file mode 100644 index 000000000..63dd1b5a5 --- /dev/null +++ b/examples/ssr/src/components/Cart.svelte @@ -0,0 +1,32 @@ +<script> + export let count = 0; + let items = new Set(); + + function onAddToCart(ev) { + const id = ev.detail; + items.add(id); + count++; + } +</script> +<style> + .cart { + display: flex; + align-items: center; + } + .cart :first-child { + margin-right: 5px; + } + + .cart-icon { + font-size: 36px; + } + + .count { + font-size: 24px; + } +</style> +<svelte:window on:add-to-cart={onAddToCart}/> +<div class="cart"> + <span class="material-icons cart-icon">shopping_cart</span> + <span class="count">{count}</span> +</div> diff --git a/examples/ssr/src/components/Container.astro b/examples/ssr/src/components/Container.astro new file mode 100644 index 000000000..f982522b8 --- /dev/null +++ b/examples/ssr/src/components/Container.astro @@ -0,0 +1,12 @@ +--- +const { tag = 'div' } = Astro.props; +const Tag = tag; +--- +<style> + .container { + width: 1248px; /** TODO: responsive */ + margin-left: auto; + margin-right: auto; + } +</style> +<Tag class="container"><slot /></Tag> diff --git a/examples/ssr/src/components/Header.astro b/examples/ssr/src/components/Header.astro new file mode 100644 index 000000000..2839c70d3 --- /dev/null +++ b/examples/ssr/src/components/Header.astro @@ -0,0 +1,31 @@ +--- +import TextDecorationSkip from './TextDecorationSkip.astro'; +import Cart from './Cart.svelte'; +--- +<style> + @import url('https://fonts.googleapis.com/css2?family=Lobster&display=swap'); + + header { + margin: 1rem 2rem; + display: flex; + justify-content: space-between; + } + + h1 { + margin: 0; + font-family: 'Lobster', cursive; + color: black; + } + + a, a:visited { + color: inherit; + text-decoration: none; + } +</style> +<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> +<header> + <h1><a href="/"><TextDecorationSkip text="Online Store" /></a></h1> + <div class="right-pane"> + <Cart client:idle /> + </div> +</header> diff --git a/examples/ssr/src/components/ProductListing.astro b/examples/ssr/src/components/ProductListing.astro new file mode 100644 index 000000000..c0af5a34c --- /dev/null +++ b/examples/ssr/src/components/ProductListing.astro @@ -0,0 +1,61 @@ +--- +const { products } = Astro.props; +--- +<style> + ul { + list-style-type: none; + margin: 0; + padding: 0; + display: flex; + } + + figure { + width: 200px; + padding: 7px; + border: 1px solid black; + display: flex; + flex-direction: column; + } + + figure figcaption { + text-align: center; + line-height: 1.6; + } + + figure img { + width: 100%; + height: 250px; + object-fit: cover; + } + + .product a { + display: block; + text-decoration: none; + color: inherit; + } + + .name { + font-weight: 500; + } + + .price { + font-size: 90%; + color: #787878; + } +</style> +<slot name="title"></slot> +<ul> +{products.map(product => ( + <li class="product"> + <a href={`/products/${product.id}`}> + <figure> + <img src={product.image} /> + <figcaption> + <div class="name">{product.name}</div> + <div class="price">${product.price}</div> + </figcaption> + </figure> + </a> + </li> +))} +</ul> diff --git a/examples/ssr/src/components/TextDecorationSkip.astro b/examples/ssr/src/components/TextDecorationSkip.astro new file mode 100644 index 000000000..b35179ea8 --- /dev/null +++ b/examples/ssr/src/components/TextDecorationSkip.astro @@ -0,0 +1,15 @@ +--- +const { text } = Astro.props; +const words = text.split(' '); +const last = words.length - 1; +--- +<style> + span { + text-decoration: underline; + } +</style> +{words.map((word, i) => ( + <Fragment> + <span>{word}</span>{i !== last && (<Fragment> </Fragment>)} + </Fragment> +))} diff --git a/examples/ssr/src/pages/index.astro b/examples/ssr/src/pages/index.astro new file mode 100644 index 000000000..ea2c6c2f6 --- /dev/null +++ b/examples/ssr/src/pages/index.astro @@ -0,0 +1,36 @@ +--- +import Header from '../components/Header.astro'; +import Container from '../components/Container.astro'; +import ProductListing from '../components/ProductListing.astro'; +import { getProducts } from '../api'; +import '../styles/common.css'; + +const products = await getProducts(); +--- +<html> +<head> + <title>Online Store</title> + <style> + h1 { + font-size: 36px; + } + + .product-listing-title { + text-align: center; + } + + .product-listing { + + } + </style> +</head> +<body> + <Header /> + + <Container tag="main"> + <ProductListing products={products} class="product-listing"> + <h2 class="product-listing-title" slot="title">Product Listing</h2> + </ProductListing> + </Container> +</body> +</html> diff --git a/examples/ssr/src/pages/products/[id].astro b/examples/ssr/src/pages/products/[id].astro new file mode 100644 index 000000000..943f2ab84 --- /dev/null +++ b/examples/ssr/src/pages/products/[id].astro @@ -0,0 +1,55 @@ +--- +import Header from '../../components/Header.astro'; +import Container from '../../components/Container.astro'; +import AddToCart from '../../components/AddToCart.svelte'; +import { getProducts, getProduct } from '../../api'; +import '../../styles/common.css'; + +export async function getStaticPaths() { + const products = await getProducts(); + return products.map(product => { + return { + params: { id: product.id.toString() } + } + }); +} + +const id = Number(Astro.request.params.id); +const product = await getProduct(id); +--- + +<html lang="en"> +<head> + <title>{product.name} | Online Store</title> + <style> + h2 { + text-align: center; + font-size: 3.5rem; + } + + figure { + display: grid; + grid-template-columns: 1fr 1fr; + } + + img { + width: 400px; + } + </style> +</head> +<body> + <Header /> + + <Container tag="article"> + <h2>{product.name}</h2> + <figure> + <img src={product.image} /> + <figcaption> + <AddToCart id={id} client:idle /> + <p>Description here...</p> + </figcaption> + </figure> + + </Container> +</body> +</html> diff --git a/examples/ssr/src/styles/common.css b/examples/ssr/src/styles/common.css new file mode 100644 index 000000000..7879df33c --- /dev/null +++ b/examples/ssr/src/styles/common.css @@ -0,0 +1,3 @@ +body { + font-family: "GT America Standard", "Helvetica Neue", Helvetica,Arial,sans-serif; +} diff --git a/packages/astro/package.json b/packages/astro/package.json index fa5cb6583..59a50e1fb 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -15,6 +15,7 @@ "types": "./dist/types/@types/astro.d.ts", "exports": { ".": "./astro.js", + "./app/node": "./dist/core/app/node.js", "./client/*": "./dist/runtime/client/*", "./components": "./components/index.js", "./components/*": "./components/*", diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index ea1dc6f4d..598530836 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -28,6 +28,7 @@ export interface CLIFlags { port?: number; config?: string; experimentalStaticBuild?: boolean; + experimentalSsr?: boolean; drafts?: boolean; } @@ -102,7 +103,7 @@ export interface AstroUserConfig { renderers?: string[]; /** Options for rendering markdown content */ markdownOptions?: { - render?: [string | MarkdownParser, Record<string, any>]; + render?: MarkdownRenderOptions; }; /** Options specific to `astro build` */ buildOptions?: { @@ -132,6 +133,10 @@ export interface AstroUserConfig { * Default: false */ experimentalStaticBuild?: boolean; + /** + * Enable a build for SSR support. + */ + experimentalSsr?: boolean; }; /** Options for the development server run with `astro dev`. */ devOptions?: { @@ -224,6 +229,7 @@ export interface ManifestData { routes: RouteData[]; } +export type MarkdownRenderOptions = [string | MarkdownParser, Record<string, any>]; export type MarkdownParser = (contents: string, options?: Record<string, any>) => MarkdownParserResponse | PromiseLike<MarkdownParserResponse>; export interface MarkdownParserResponse { @@ -341,6 +347,11 @@ export interface RouteData { type: 'page'; } +export type SerializedRouteData = Omit<RouteData, 'generate' | 'pattern'> & { + generate: undefined; + pattern: string; +}; + export type RuntimeMode = 'development' | 'production'; /** diff --git a/packages/astro/src/cli/index.ts b/packages/astro/src/cli/index.ts index b37edec1c..ce3be2efb 100644 --- a/packages/astro/src/cli/index.ts +++ b/packages/astro/src/cli/index.ts @@ -31,6 +31,7 @@ function printHelp() { --project-root <path> Specify the path to the project root folder. --no-sitemap Disable sitemap generation (build only). --experimental-static-build A more performant build that expects assets to be define statically. + --experimental-ssr Enable SSR compilation. --drafts Include markdown draft pages in the build. --verbose Enable verbose logging --silent Disable logging diff --git a/packages/astro/src/core/app/common.ts b/packages/astro/src/core/app/common.ts new file mode 100644 index 000000000..ef6d1ae74 --- /dev/null +++ b/packages/astro/src/core/app/common.ts @@ -0,0 +1,20 @@ +import type { SSRManifest, SerializedSSRManifest, RouteInfo } from './types'; +import { deserializeRouteData } from '../routing/manifest/serialization.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); + } + + return { + ...serializedManifest, + routes + }; +} diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts new file mode 100644 index 000000000..38e5c3d6f --- /dev/null +++ b/packages/astro/src/core/app/index.ts @@ -0,0 +1,100 @@ +import type { ComponentInstance, ManifestData, RouteData, Renderer } from '../../@types/astro'; +import type { + SSRManifest as Manifest, RouteInfo +} from './types'; + +import { defaultLogOptions } from '../logger.js'; +import { matchRoute } from '../routing/match.js'; +import { render } from '../render/core.js'; +import { RouteCache } from '../render/route-cache.js'; +import { createLinkStylesheetElementSet, createModuleScriptElementWithSrcSet } from '../render/ssr-element.js'; +import { createRenderer } from '../render/renderer.js'; +import { prependForwardSlash } from '../path.js'; + +export class App { + #manifest: Manifest; + #manifestData: ManifestData; + #rootFolder: URL; + #routeDataToRouteInfo: Map<RouteData, RouteInfo>; + #routeCache: RouteCache; + #renderersPromise: Promise<Renderer[]>; + + constructor(manifest: Manifest, rootFolder: URL) { + this.#manifest = manifest; + this.#manifestData = { + routes: manifest.routes.map(route => route.routeData) + }; + this.#rootFolder = rootFolder; + this.#routeDataToRouteInfo = new Map( + manifest.routes.map(route => [route.routeData, route]) + ); + this.#routeCache = new RouteCache(defaultLogOptions); + this.#renderersPromise = this.#loadRenderers(); + } + match({ pathname }: URL): RouteData | undefined { + return matchRoute(pathname, this.#manifestData); + } + async render(url: URL, routeData?: RouteData): Promise<string> { + if(!routeData) { + routeData = this.match(url); + if(!routeData) { + return 'Not found'; + } + } + + const manifest = this.#manifest; + const info = this.#routeDataToRouteInfo.get(routeData!)!; + const [mod, renderers] = await Promise.all([ + this.#loadModule(info.file), + this.#renderersPromise + ]); + + const links = createLinkStylesheetElementSet(info.links, manifest.site); + const scripts = createModuleScriptElementWithSrcSet(info.scripts, manifest.site); + + return render({ + experimentalStaticBuild: true, + links, + logging: defaultLogOptions, + markdownRender: manifest.markdown.render, + mod, + origin: url.origin, + pathname: url.pathname, + scripts, + renderers, + async resolve(specifier: string) { + if(!(specifier in manifest.entryModules)) { + throw new Error(`Unable to resolve [${specifier}]`); + } + const bundlePath = manifest.entryModules[specifier]; + return prependForwardSlash(bundlePath); + }, + route: routeData, + routeCache: this.#routeCache, + site: this.#manifest.site + }) + } + async #loadRenderers(): Promise<Renderer[]> { + const rendererNames = this.#manifest.renderers; + return await Promise.all(rendererNames.map(async (rendererName) => { + return createRenderer(rendererName, { + renderer(name) { + return import(name); + }, + server(entry) { + return import(entry); + } + }) + })); + } + async #loadModule(rootRelativePath: string): Promise<ComponentInstance> { + let modUrl = new URL(rootRelativePath, this.#rootFolder).toString(); + let mod: ComponentInstance; + try { + mod = await import(modUrl); + return mod; + } catch(err) { + throw new Error(`Unable to import ${modUrl}. Does this file exist?`); + } + } +} diff --git a/packages/astro/src/core/app/node.ts b/packages/astro/src/core/app/node.ts new file mode 100644 index 000000000..d1bcbf46b --- /dev/null +++ b/packages/astro/src/core/app/node.ts @@ -0,0 +1,31 @@ +import type { SSRManifest, SerializedSSRManifest } from './types'; + +import * as fs from 'fs'; +import { App } from './index.js'; +import { deserializeManifest } from './common.js'; +import { IncomingMessage } from 'http'; + +function createURLFromRequest(req: IncomingMessage): URL { + return new URL(`http://${req.headers.host}${req.url}`); +} + +class NodeApp extends App { + match(req: IncomingMessage | URL) { + return super.match(req instanceof URL ? req : createURLFromRequest(req)); + } + render(req: IncomingMessage | URL) { + return super.render(req instanceof URL ? req : createURLFromRequest(req)); + } +} + +export async function loadManifest(rootFolder: URL): Promise<SSRManifest> { + const manifestFile = new URL('./manifest.json', rootFolder); + const rawManifest = await fs.promises.readFile(manifestFile, 'utf-8'); + const serializedManifest: SerializedSSRManifest = JSON.parse(rawManifest); + return deserializeManifest(serializedManifest); +} + +export async function loadApp(rootFolder: URL): Promise<NodeApp> { + const manifest = await loadManifest(rootFolder); + return new NodeApp(manifest, rootFolder); +} diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts new file mode 100644 index 000000000..8799ef1c9 --- /dev/null +++ b/packages/astro/src/core/app/types.ts @@ -0,0 +1,26 @@ +import type { RouteData, SerializedRouteData, MarkdownRenderOptions } from '../../@types/astro'; + +export interface RouteInfo { + routeData: RouteData + file: string; + links: string[]; + scripts: string[]; +} + +export type SerializedRouteInfo = Omit<RouteInfo, 'routeData'> & { + routeData: SerializedRouteData; +} + +export interface SSRManifest { + routes: RouteInfo[]; + site?: string; + markdown: { + render: MarkdownRenderOptions + }, + renderers: string[]; + entryModules: Record<string, string>; +} + +export type SerializedSSRManifest = Omit<SSRManifest, 'routes'> & { + routes: SerializedRouteInfo[]; +} diff --git a/packages/astro/src/core/build/index.ts b/packages/astro/src/core/build/index.ts index b54e68622..68b12603d 100644 --- a/packages/astro/src/core/build/index.ts +++ b/packages/astro/src/core/build/index.ts @@ -8,12 +8,12 @@ import { performance } from 'perf_hooks'; import vite, { ViteDevServer } from '../vite.js'; import { createVite, ViteConfigWithSSR } from '../create-vite.js'; import { debug, defaultLogOptions, info, levels, timerMessage, warn } from '../logger.js'; -import { createRouteManifest } from '../ssr/routing.js'; -import { generateSitemap } from '../ssr/sitemap.js'; +import { createRouteManifest } from '../routing/index.js'; +import { generateSitemap } from '../render/sitemap.js'; import { collectPagesData } from './page-data.js'; import { build as scanBasedBuild } from './scan-based-build.js'; import { staticBuild } from './static-build.js'; -import { RouteCache } from '../ssr/route-cache.js'; +import { RouteCache } from '../render/route-cache.js'; export interface BuildOptions { mode?: string; @@ -115,6 +115,7 @@ class AstroBuilder { allPages, astroConfig: this.config, logging: this.logging, + manifest: this.manifest, origin: this.origin, pageNames, routeCache: this.routeCache, diff --git a/packages/astro/src/core/build/page-data.ts b/packages/astro/src/core/build/page-data.ts index 106e09a05..945423080 100644 --- a/packages/astro/src/core/build/page-data.ts +++ b/packages/astro/src/core/build/page-data.ts @@ -1,4 +1,4 @@ -import type { AstroConfig, ComponentInstance, ManifestData, RouteData, RSSResult } from '../../@types/astro'; +import type { AstroConfig, ComponentInstance, ManifestData, RouteData } from '../../@types/astro'; import type { AllPagesData } from './types'; import type { LogOptions } from '../logger'; import type { ViteDevServer } from '../vite.js'; @@ -6,9 +6,9 @@ import type { ViteDevServer } from '../vite.js'; import { fileURLToPath } from 'url'; import * as colors from 'kleur/colors'; import { debug } from '../logger.js'; -import { preload as ssrPreload } from '../ssr/index.js'; -import { generateRssFunction } from '../ssr/rss.js'; -import { callGetStaticPaths, RouteCache, RouteCacheEntry } from '../ssr/route-cache.js'; +import { preload as ssrPreload } from '../render/dev/index.js'; +import { generateRssFunction } from '../render/rss.js'; +import { callGetStaticPaths, RouteCache, RouteCacheEntry } from '../render/route-cache.js'; export interface CollectPagesDataOptions { astroConfig: AstroConfig; diff --git a/packages/astro/src/core/build/scan-based-build.ts b/packages/astro/src/core/build/scan-based-build.ts index c11795fd8..e6d380b61 100644 --- a/packages/astro/src/core/build/scan-based-build.ts +++ b/packages/astro/src/core/build/scan-based-build.ts @@ -9,7 +9,7 @@ import vite from '../vite.js'; import { createBuildInternals } from '../../core/build/internal.js'; import { rollupPluginAstroBuildHTML } from '../../vite-plugin-build-html/index.js'; import { rollupPluginAstroBuildCSS } from '../../vite-plugin-build-css/index.js'; -import { RouteCache } from '../ssr/route-cache.js'; +import { RouteCache } from '../render/route-cache.js'; export interface ScanBasedBuildOptions { allPages: AllPagesData; diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index 22255148d..3ab3e0cb4 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -1,12 +1,12 @@ -import type { OutputChunk, OutputAsset, PreRenderedChunk, RollupOutput } from 'rollup'; -import type { Plugin as VitePlugin, UserConfig } from '../vite'; -import type { AstroConfig, Renderer, SSRElement } from '../../@types/astro'; +import type { OutputChunk, OutputAsset, RollupOutput } from 'rollup'; +import type { Plugin as VitePlugin, UserConfig, Manifest as ViteManifest } from '../vite'; +import type { AstroConfig, ComponentInstance, ManifestData, Renderer } from '../../@types/astro'; import type { AllPagesData } from './types'; import type { LogOptions } from '../logger'; import type { ViteConfigWithSSR } from '../create-vite'; import type { PageBuildData } from './types'; import type { BuildInternals } from '../../core/build/internal.js'; -import type { AstroComponentFactory } from '../../runtime/server'; +import type { SerializedSSRManifest, SerializedRouteInfo } from '../app/types'; import fs from 'fs'; import npath from 'path'; @@ -17,17 +17,18 @@ import { debug, error } from '../../core/logger.js'; import { prependForwardSlash, appendForwardSlash } from '../../core/path.js'; import { createBuildInternals } from '../../core/build/internal.js'; import { rollupPluginAstroBuildCSS } from '../../vite-plugin-build-css/index.js'; -import { getParamsAndProps } from '../ssr/index.js'; -import { createResult } from '../ssr/result.js'; -import { renderPage } from '../../runtime/server/index.js'; -import { prepareOutDir } from './fs.js'; +import { emptyDir, prepareOutDir } from './fs.js'; import { vitePluginHoistedScripts } from './vite-plugin-hoisted-scripts.js'; -import { RouteCache } from '../ssr/route-cache.js'; +import { RouteCache } from '../render/route-cache.js'; +import { serializeRouteData } from '../routing/index.js'; +import { render } from '../render/core.js'; +import { createLinkStylesheetElementSet, createModuleScriptElementWithSrcSet } from '../render/ssr-element.js'; export interface StaticBuildOptions { allPages: AllPagesData; astroConfig: AstroConfig; logging: LogOptions; + manifest: ManifestData; origin: string; pageNames: string[]; routeCache: RouteCache; @@ -41,6 +42,12 @@ function addPageName(pathname: string, opts: StaticBuildOptions): void { opts.pageNames.push(pathname.replace(/\/?$/, pathrepl).replace(/^\//, '')); } +// Gives back a facadeId that is relative to the root. +// ie, src/pages/index.astro instead of /Users/name..../src/pages/index.astro +function rootRelativeFacadeId(facadeId: string, astroConfig: AstroConfig): string { + return facadeId.slice(fileURLToPath(astroConfig.projectRoot).length); +} + // Determines of a Rollup chunk is an entrypoint page. function chunkIsPage(astroConfig: AstroConfig, output: OutputAsset | OutputChunk, internals: BuildInternals) { if (output.type !== 'chunk') { @@ -48,7 +55,7 @@ function chunkIsPage(astroConfig: AstroConfig, output: OutputAsset | OutputChunk } const chunk = output as OutputChunk; if (chunk.facadeModuleId) { - const facadeToEntryId = prependForwardSlash(chunk.facadeModuleId.slice(fileURLToPath(astroConfig.projectRoot).length)); + const facadeToEntryId = prependForwardSlash(rootRelativeFacadeId(chunk.facadeModuleId, astroConfig)); return internals.entrySpecifierToBundleMap.has(facadeToEntryId); } return false; @@ -88,6 +95,9 @@ function getByFacadeId<T>(facadeId: string, map: Map<string, T>): T | undefined export async function staticBuild(opts: StaticBuildOptions) { const { allPages, astroConfig } = opts; + // Basic options + const staticMode = !astroConfig.buildOptions.experimentalSsr; + // The pages to be built for rendering purposes. const pageInput = new Set<string>(); @@ -148,26 +158,38 @@ export async function staticBuild(opts: StaticBuildOptions) { // Run the SSR build and client build in parallel const [ssrResult] = (await Promise.all([ssrBuild(opts, internals, pageInput), clientBuild(opts, internals, jsInput)])) as RollupOutput[]; - // Generate each of the pages. - await generatePages(ssrResult, opts, internals, facadeIdToPageDataMap); - await cleanSsrOutput(opts); + // SSG mode, generate pages. + if(staticMode) { + // Generate each of the pages. + await generatePages(ssrResult, opts, internals, facadeIdToPageDataMap); + await cleanSsrOutput(opts); + } else { + await generateManifest(ssrResult, opts, internals); + await ssrMoveAssets(opts); + } } async function ssrBuild(opts: StaticBuildOptions, internals: BuildInternals, input: Set<string>) { const { astroConfig, viteConfig } = opts; + const ssr = astroConfig.buildOptions.experimentalSsr; + const out = ssr ? getServerRoot(astroConfig) : getOutRoot(astroConfig); return await vite.build({ logLevel: 'error', mode: 'production', build: { emptyOutDir: false, + manifest: ssr, minify: false, - outDir: fileURLToPath(getOutRoot(astroConfig)), + outDir: fileURLToPath(out), ssr: true, rollupOptions: { input: Array.from(input), output: { format: 'esm', + entryFileNames: '[name].[hash].mjs', + chunkFileNames: 'chunks/[name].[hash].mjs', + assetFileNames: 'assets/[name].[hash][extname]' }, }, target: 'esnext', // must match an esbuild target @@ -179,7 +201,7 @@ async function ssrBuild(opts: StaticBuildOptions, internals: BuildInternals, inp }), ...(viteConfig.plugins || []), ], - publicDir: viteConfig.publicDir, + publicDir: ssr ? false : viteConfig.publicDir, root: viteConfig.root, envPrefix: 'PUBLIC_', server: viteConfig.server, @@ -196,17 +218,23 @@ async function clientBuild(opts: StaticBuildOptions, internals: BuildInternals, return null; } + const out = astroConfig.buildOptions.experimentalSsr ? getClientRoot(astroConfig) : getOutRoot(astroConfig); + return await vite.build({ logLevel: 'error', mode: 'production', build: { emptyOutDir: false, minify: 'esbuild', - outDir: fileURLToPath(getOutRoot(astroConfig)), + outDir: fileURLToPath(out), rollupOptions: { input: Array.from(input), output: { format: 'esm', + entryFileNames: '[name].[hash].js', + chunkFileNames: 'chunks/[name].[hash].js', + assetFileNames: 'assets/[name].[hash][extname]' + }, preserveEntrySignatures: 'exports-only', }, @@ -285,14 +313,13 @@ async function generatePage(output: OutputChunk, opts: StaticBuildOptions, inter const hoistedId = getByFacadeId<string>(facadeId, internals.facadeIdToHoistedEntryMap) || null; let compiledModule = await import(url.toString()); - let Component = compiledModule.default; const generationOptions: Readonly<GeneratePathOptions> = { pageData, internals, linkIds, hoistedId, - Component, + mod: compiledModule, renderers, }; @@ -314,65 +341,48 @@ interface GeneratePathOptions { internals: BuildInternals; linkIds: string[]; hoistedId: string | null; - Component: AstroComponentFactory; + mod: ComponentInstance; renderers: Renderer[]; } async function generatePath(pathname: string, opts: StaticBuildOptions, gopts: GeneratePathOptions) { const { astroConfig, logging, origin, routeCache } = opts; - const { Component, internals, linkIds, hoistedId, pageData, renderers } = gopts; + const { mod, internals, linkIds, hoistedId, pageData, renderers } = gopts; // This adds the page name to the array so it can be shown as part of stats. addPageName(pathname, opts); - const [, mod] = pageData.preload; + debug('build', `Generating: ${pathname}`); + + const site = astroConfig.buildOptions.site; + const links = createLinkStylesheetElementSet(linkIds, site); + const scripts = createModuleScriptElementWithSrcSet(hoistedId ? [hoistedId] : [], site); try { - const [params, pageProps] = await getParamsAndProps({ + const html = await render({ + experimentalStaticBuild: true, + links, + logging, + markdownRender: astroConfig.markdownOptions.render, + mod, + origin, + pathname, + scripts, + renderers, + async resolve(specifier: string) { + const hashedFilePath = internals.entrySpecifierToBundleMap.get(specifier); + if (typeof hashedFilePath !== 'string') { + throw new Error(`Cannot find the built path for ${specifier}`); + } + const relPath = npath.posix.relative(pathname, '/' + hashedFilePath); + const fullyRelativePath = relPath[0] === '.' ? relPath : './' + relPath; + return fullyRelativePath; + }, route: pageData.route, routeCache, - pathname, + site: astroConfig.buildOptions.site, }); - debug('build', `Generating: ${pathname}`); - - const rootpath = appendForwardSlash(new URL(astroConfig.buildOptions.site || 'http://localhost/').pathname); - const links = new Set<SSRElement>( - linkIds.map((href) => ({ - props: { - rel: 'stylesheet', - href: npath.posix.join(rootpath, href), - }, - children: '', - })) - ); - const scripts = hoistedId - ? new Set<SSRElement>([ - { - props: { - type: 'module', - src: npath.posix.join(rootpath, hoistedId), - }, - children: '', - }, - ]) - : new Set<SSRElement>(); - const result = createResult({ astroConfig, logging, origin, params, pathname, renderers, links, scripts }); - - // Override the `resolve` method so that hydrated components are given the - // hashed filepath to the component. - result.resolve = async (specifier: string) => { - const hashedFilePath = internals.entrySpecifierToBundleMap.get(specifier); - if (typeof hashedFilePath !== 'string') { - throw new Error(`Cannot find the built path for ${specifier}`); - } - const relPath = npath.posix.relative(pathname, '/' + hashedFilePath); - const fullyRelativePath = relPath[0] === '.' ? relPath : './' + relPath; - return fullyRelativePath; - }; - - let html = await renderPage(result, Component, pageProps, null); - const outFolder = getOutFolder(astroConfig, pathname); const outFile = getOutFile(astroConfig, outFolder, pathname); await fs.promises.mkdir(outFolder, { recursive: true }); @@ -382,11 +392,79 @@ async function generatePath(pathname: string, opts: StaticBuildOptions, gopts: G } } +async function generateManifest(result: RollupOutput, opts: StaticBuildOptions, internals: BuildInternals) { + const { astroConfig, manifest } = opts; + const manifestFile = new URL('./manifest.json', getServerRoot(astroConfig)); + + const inputManifestJSON = await fs.promises.readFile(manifestFile, 'utf-8'); + const data: ViteManifest = JSON.parse(inputManifestJSON); + + const rootRelativeIdToChunkMap = new Map<string, OutputChunk>(); + for(const output of result.output) { + if(chunkIsPage(astroConfig, output, internals)) { + const chunk = output as OutputChunk; + if(chunk.facadeModuleId) { + const id = rootRelativeFacadeId(chunk.facadeModuleId, astroConfig); + rootRelativeIdToChunkMap.set(id, chunk); + } + } + } + + const routes: SerializedRouteInfo[] = []; + + for(const routeData of manifest.routes) { + const componentPath = routeData.component; + const entry = data[componentPath]; + + if(!rootRelativeIdToChunkMap.has(componentPath)) { + throw new Error('Unable to find chunk for ' + componentPath); + } + + const chunk = rootRelativeIdToChunkMap.get(componentPath)!; + const facadeId = chunk.facadeModuleId!; + const links = getByFacadeId<string[]>(facadeId, internals.facadeIdToAssetsMap) || []; + const hoistedScript = getByFacadeId<string>(facadeId, internals.facadeIdToHoistedEntryMap); + const scripts = hoistedScript ? [hoistedScript] : []; + + routes.push({ + file: entry?.file, + links, + scripts, + routeData: serializeRouteData(routeData) + }); + } + + const ssrManifest: SerializedSSRManifest = { + routes, + site: astroConfig.buildOptions.site, + markdown: { + render: astroConfig.markdownOptions.render + }, + renderers: astroConfig.renderers, + entryModules: Object.fromEntries(internals.entrySpecifierToBundleMap.entries()) + }; + + const outputManifestJSON = JSON.stringify(ssrManifest, null, ' '); + await fs.promises.writeFile(manifestFile, outputManifestJSON, 'utf-8'); +} + function getOutRoot(astroConfig: AstroConfig): URL { const rootPathname = appendForwardSlash(astroConfig.buildOptions.site ? new URL(astroConfig.buildOptions.site).pathname : '/'); return new URL('.' + rootPathname, astroConfig.dist); } +function getServerRoot(astroConfig: AstroConfig): URL { + const rootFolder = getOutRoot(astroConfig); + const serverFolder = new URL('./server/', rootFolder); + return serverFolder; +} + +function getClientRoot(astroConfig: AstroConfig): URL { + const rootFolder = getOutRoot(astroConfig); + const serverFolder = new URL('./client/', rootFolder); + return serverFolder; +} + function getOutFolder(astroConfig: AstroConfig, pathname: string): URL { const outRoot = getOutRoot(astroConfig); @@ -421,6 +499,34 @@ async function cleanSsrOutput(opts: StaticBuildOptions) { ); } +async function ssrMoveAssets(opts: StaticBuildOptions) { + const { astroConfig } = opts; + const serverRoot = getServerRoot(astroConfig); + const clientRoot = getClientRoot(astroConfig); + const serverAssets = new URL('./assets/', serverRoot); + const clientAssets = new URL('./assets/', clientRoot); + const files = await glob('assets/**/*', { + cwd: fileURLToPath(serverRoot), + }); + + // Make the directory + await fs.promises.mkdir(clientAssets, { recursive: true }); + + await Promise.all( + files.map(async (filename) => { + const currentUrl = new URL(filename, serverRoot); + const clientUrl = new URL(filename, clientRoot); + return fs.promises.rename(currentUrl, clientUrl); + }) + ); + + await emptyDir(fileURLToPath(serverAssets)); + + if(fs.existsSync(serverAssets)) { + await fs.promises.rmdir(serverAssets); + } +} + export function vitePluginNewBuild(input: Set<string>, internals: BuildInternals, ext: 'js' | 'mjs'): VitePlugin { return { name: '@astro/rollup-plugin-new-build', @@ -451,18 +557,6 @@ export function vitePluginNewBuild(input: Set<string>, internals: BuildInternals } }, - outputOptions(outputOptions) { - Object.assign(outputOptions, { - entryFileNames(_chunk: PreRenderedChunk) { - return 'assets/[name].[hash].' + ext; - }, - chunkFileNames(_chunk: PreRenderedChunk) { - return 'assets/[name].[hash].' + ext; - }, - }); - return outputOptions; - }, - async generateBundle(_options, bundle) { const promises = []; const mapping = new Map<string, string>(); diff --git a/packages/astro/src/core/build/types.d.ts b/packages/astro/src/core/build/types.d.ts index 2606075e2..fa37ff888 100644 --- a/packages/astro/src/core/build/types.d.ts +++ b/packages/astro/src/core/build/types.d.ts @@ -1,4 +1,4 @@ -import type { ComponentPreload } from '../ssr/index'; +import type { ComponentPreload } from '../render/dev/index'; import type { RouteData } from '../../@types/astro'; export interface PageBuildData { diff --git a/packages/astro/src/core/config.ts b/packages/astro/src/core/config.ts index 186677802..a8ddd5b79 100644 --- a/packages/astro/src/core/config.ts +++ b/packages/astro/src/core/config.ts @@ -63,6 +63,7 @@ export const AstroConfigSchema = z.object({ .optional() .default('directory'), experimentalStaticBuild: z.boolean().optional().default(false), + experimentalSsr: z.boolean().optional().default(false), drafts: z.boolean().optional().default(false), }) .optional() @@ -130,6 +131,7 @@ function resolveFlags(flags: Partial<Flags>): CLIFlags { config: typeof flags.config === 'string' ? flags.config : undefined, hostname: typeof flags.hostname === 'string' ? flags.hostname : undefined, experimentalStaticBuild: typeof flags.experimentalStaticBuild === 'boolean' ? flags.experimentalStaticBuild : false, + experimentalSsr: typeof flags.experimentalSsr === 'boolean' ? flags.experimentalSsr : false, drafts: typeof flags.drafts === 'boolean' ? flags.drafts : false, }; } @@ -143,6 +145,12 @@ function mergeCLIFlags(astroConfig: AstroUserConfig, flags: CLIFlags) { if (typeof flags.port === 'number') astroConfig.devOptions.port = flags.port; if (typeof flags.hostname === 'string') astroConfig.devOptions.hostname = flags.hostname; if (typeof flags.experimentalStaticBuild === 'boolean') astroConfig.buildOptions.experimentalStaticBuild = flags.experimentalStaticBuild; + if (typeof flags.experimentalSsr === 'boolean') { + astroConfig.buildOptions.experimentalSsr = flags.experimentalSsr; + if(flags.experimentalSsr) { + astroConfig.buildOptions.experimentalStaticBuild = true; + } + } if (typeof flags.drafts === 'boolean') astroConfig.buildOptions.drafts = flags.drafts; return astroConfig; } diff --git a/packages/astro/src/core/render/core.ts b/packages/astro/src/core/render/core.ts new file mode 100644 index 000000000..eea5afa33 --- /dev/null +++ b/packages/astro/src/core/render/core.ts @@ -0,0 +1,119 @@ +import type { ComponentInstance, MarkdownRenderOptions, Params, Props, Renderer, RouteData, SSRElement } from '../../@types/astro'; +import type { LogOptions } from '../logger.js'; + +import { renderPage } from '../../runtime/server/index.js'; +import { getParams } from '../routing/index.js'; +import { createResult } from './result.js'; +import { findPathItemByKey, RouteCache, callGetStaticPaths } from './route-cache.js'; +import { warn } from '../logger.js'; + +interface GetParamsAndPropsOptions { + mod: ComponentInstance; + route: RouteData | undefined; + routeCache: RouteCache; + pathname: string; + logging: LogOptions; +} + +async function getParamsAndProps(opts: GetParamsAndPropsOptions): Promise<[Params, Props]> { + const { logging, mod, route, routeCache, pathname } = opts; + // Handle dynamic routes + let params: Params = {}; + let pageProps: Props; + if (route && !route.pathname) { + if (route.params.length) { + const paramsMatch = route.pattern.exec(pathname); + if (paramsMatch) { + params = getParams(route.params)(paramsMatch); + } + } + let routeCacheEntry = routeCache.get(route); + if (!routeCacheEntry) { + warn(logging, 'routeCache', `Internal Warning: getStaticPaths() called twice during the build. (${route.component})`); + routeCacheEntry = await callGetStaticPaths(mod, route, true, logging); + routeCache.set(route, routeCacheEntry); + } + const paramsKey = JSON.stringify(params); + const matchedStaticPath = findPathItemByKey(routeCacheEntry.staticPaths, paramsKey); + if (!matchedStaticPath) { + throw new Error(`[getStaticPaths] route pattern matched, but no matching static path found. (${pathname})`); + } + // This is written this way for performance; instead of spreading the props + // which is O(n), create a new object that extends props. + pageProps = Object.create(matchedStaticPath.props || Object.prototype); + } else { + pageProps = {}; + } + return [params, pageProps]; +} + +interface RenderOptions { + experimentalStaticBuild: boolean; + logging: LogOptions, + links: Set<SSRElement>; + markdownRender: MarkdownRenderOptions, + mod: ComponentInstance; + origin: string; + pathname: string; + scripts: Set<SSRElement>; + resolve: (s: string) => Promise<string>; + renderers: Renderer[]; + route?: RouteData; + routeCache: RouteCache; + site?: string; +} + +export async function render(opts: RenderOptions): Promise<string> { + const { + experimentalStaticBuild, + links, + logging, + origin, + markdownRender, + mod, + pathname, + scripts, + renderers, + resolve, + route, + routeCache, + site + } = opts; + + const [params, pageProps] = await getParamsAndProps({ + logging, + mod, + route, + routeCache, + pathname, + }); + + // Validate the page component before rendering the page + const Component = await mod.default; + if (!Component) throw new Error(`Expected an exported Astro component but received typeof ${typeof Component}`); + if (!Component.isAstroComponentFactory) throw new Error(`Unable to SSR non-Astro component (${route?.component})`); + + + const result = createResult({ + experimentalStaticBuild, + links, + logging, + markdownRender, + origin, + params, + pathname, + resolve, + renderers, + site, + scripts + }); + + let html = await renderPage(result, Component, pageProps, null); + + // inject <!doctype html> if missing (TODO: is a more robust check needed for comments, etc.?) + if (experimentalStaticBuild && !/<!doctype html/i.test(html)) { + html = '<!DOCTYPE html>\n' + html; + } + + return html; +} diff --git a/packages/astro/src/core/ssr/css.ts b/packages/astro/src/core/render/dev/css.ts index 4ee0e80d8..196fdafd4 100644 --- a/packages/astro/src/core/ssr/css.ts +++ b/packages/astro/src/core/render/dev/css.ts @@ -1,7 +1,7 @@ -import type vite from '../vite'; +import type vite from '../../vite'; import path from 'path'; -import { viteID } from '../util.js'; +import { viteID } from '../../util.js'; // https://vitejs.dev/guide/features.html#css-pre-processors export const STYLE_EXTENSIONS = new Set(['.css', '.pcss', '.postcss', '.scss', '.sass', '.styl', '.stylus', '.less']); diff --git a/packages/astro/src/core/render/dev/error.ts b/packages/astro/src/core/render/dev/error.ts new file mode 100644 index 000000000..aa5a18083 --- /dev/null +++ b/packages/astro/src/core/render/dev/error.ts @@ -0,0 +1,44 @@ +import type { BuildResult } from 'esbuild'; +import type vite from '../../vite'; +import type { SSRError } from '../../../@types/astro'; + +import eol from 'eol'; +import fs from 'fs'; +import { codeFrame } from '../../util.js'; + +interface ErrorHandlerOptions { + filePath: URL; + viteServer: vite.ViteDevServer; +} + +export async function errorHandler(e: unknown, { viteServer, filePath }: ErrorHandlerOptions) { + // normalize error stack line-endings to \n + if ((e as any).stack) { + (e as any).stack = eol.lf((e as any).stack); + } + + // fix stack trace with Vite (this searches its module graph for matches) + if (e instanceof Error) { + viteServer.ssrFixStacktrace(e); + } + + // Astro error (thrown by esbuild so it needs to be formatted for Vite) + if (Array.isArray((e as any).errors)) { + const { location, pluginName, text } = (e as BuildResult).errors[0]; + const err = e as SSRError; + if (location) err.loc = { file: location.file, line: location.line, column: location.column }; + let src = err.pluginCode; + if (!src && err.id && fs.existsSync(err.id)) src = await fs.promises.readFile(err.id, 'utf8'); + if (!src) src = await fs.promises.readFile(filePath, 'utf8'); + err.frame = codeFrame(src, err.loc); + err.id = location?.file; + err.message = `${location?.file}: ${text} +${err.frame} +`; + if (pluginName) err.plugin = pluginName; + throw err; + } + + // Generic error (probably from Vite, and already formatted) + throw e; +} diff --git a/packages/astro/src/core/render/dev/hmr.ts b/packages/astro/src/core/render/dev/hmr.ts new file mode 100644 index 000000000..3c795fdb1 --- /dev/null +++ b/packages/astro/src/core/render/dev/hmr.ts @@ -0,0 +1,11 @@ +import fs from 'fs'; +import { fileURLToPath } from 'url'; + +let hmrScript: string; +export async function getHmrScript() { + if (hmrScript) return hmrScript; + const filePath = fileURLToPath(new URL('../../../runtime/client/hmr.js', import.meta.url)); + const content = await fs.promises.readFile(filePath); + hmrScript = content.toString(); + return hmrScript; +} diff --git a/packages/astro/src/core/ssr/html.ts b/packages/astro/src/core/render/dev/html.ts index eb429b927..2ae147ade 100644 --- a/packages/astro/src/core/ssr/html.ts +++ b/packages/astro/src/core/render/dev/html.ts @@ -1,4 +1,4 @@ -import type vite from '../vite'; +import type vite from '../../vite'; import htmlparser2 from 'htmlparser2'; diff --git a/packages/astro/src/core/render/dev/index.ts b/packages/astro/src/core/render/dev/index.ts new file mode 100644 index 000000000..70f142c33 --- /dev/null +++ b/packages/astro/src/core/render/dev/index.ts @@ -0,0 +1,158 @@ +import type vite from '../../vite'; +import type { AstroConfig, ComponentInstance, Renderer, RouteData, RuntimeMode } from '../../../@types/astro'; +import { LogOptions } from '../../logger.js'; +import { fileURLToPath } from 'url'; +import { getStylesForURL } from './css.js'; +import { injectTags } from './html.js'; +import { RouteCache } from '../route-cache.js'; +import { resolveRenderers } from './renderers.js'; +import { errorHandler } from './error.js'; +import { getHmrScript } from './hmr.js'; +import { render as coreRender } from '../core.js'; +import { createModuleScriptElementWithSrcSet } from '../ssr-element.js'; + +interface SSROptions { + /** an instance of the AstroConfig */ + astroConfig: AstroConfig; + /** location of file on disk */ + filePath: URL; + /** logging options */ + logging: LogOptions; + /** "development" or "production" */ + mode: RuntimeMode; + /** production website, needed for some RSS & Sitemap functions */ + origin: string; + /** the web request (needed for dynamic routes) */ + pathname: string; + /** optional, in case we need to render something outside of a dev server */ + route?: RouteData; + /** pass in route cache because SSR can’t manage cache-busting */ + routeCache: RouteCache; + /** Vite instance */ + viteServer: vite.ViteDevServer; +} + +export type ComponentPreload = [Renderer[], ComponentInstance]; + +const svelteStylesRE = /svelte\?svelte&type=style/; + +export async function preload({ astroConfig, filePath, viteServer }: SSROptions): Promise<ComponentPreload> { + // Important: This needs to happen first, in case a renderer provides polyfills. + const renderers = await resolveRenderers(viteServer, astroConfig); + // Load the module from the Vite SSR Runtime. + const mod = (await viteServer.ssrLoadModule(fileURLToPath(filePath))) as ComponentInstance; + + return [renderers, mod]; +} + +/** use Vite to SSR */ +export async function render(renderers: Renderer[], mod: ComponentInstance, ssrOpts: SSROptions): Promise<string> { + const { astroConfig, filePath, logging, mode, origin, pathname, route, routeCache, viteServer } = ssrOpts; + + // Add hoisted script tags + const scripts = createModuleScriptElementWithSrcSet(astroConfig.buildOptions.experimentalStaticBuild ? + Array.from(mod.$$metadata.hoistedScriptPaths()) : + [] + ); + + // Inject HMR scripts + if (mode === 'development' && astroConfig.buildOptions.experimentalStaticBuild) { + scripts.add({ + props: { type: 'module', src: '/@vite/client' }, + children: '', + }); + scripts.add({ + props: { type: 'module', src: new URL('../../runtime/client/hmr.js', import.meta.url).pathname }, + children: '', + }); + } + + let html = await coreRender({ + experimentalStaticBuild: astroConfig.buildOptions.experimentalStaticBuild, + links: new Set(), + logging, + markdownRender: astroConfig.markdownOptions.render, + mod, + origin, + pathname, + scripts, + // Resolves specifiers in the inline hydrated scripts, such as "@astrojs/renderer-preact/client.js" + async resolve(s: string) { + // The legacy build needs these to remain unresolved so that vite HTML + // Can do the resolution. Without this condition the build output will be + // broken in the legacy build. This can be removed once the legacy build is removed. + if (astroConfig.buildOptions.experimentalStaticBuild) { + const [, resolvedPath] = await viteServer.moduleGraph.resolveUrl(s); + return resolvedPath; + } else { + return s; + } + }, + renderers, + route, + routeCache, + site: astroConfig.buildOptions.site, + }); + + // inject tags + const tags: vite.HtmlTagDescriptor[] = []; + + // dev only: inject Astro HMR client + if (mode === 'development' && !astroConfig.buildOptions.experimentalStaticBuild) { + tags.push({ + tag: 'script', + attrs: { type: 'module' }, + // HACK: inject the direct contents of our `astro/runtime/client/hmr.js` to ensure + // `import.meta.hot` is properly handled by Vite + children: await getHmrScript(), + injectTo: 'head', + }); + } + + // inject CSS + [...getStylesForURL(filePath, viteServer)].forEach((href) => { + if (mode === 'development' && svelteStylesRE.test(href)) { + tags.push({ + tag: 'script', + attrs: { type: 'module', src: href }, + injectTo: 'head', + }); + } else { + tags.push({ + tag: 'link', + attrs: { + rel: 'stylesheet', + href, + 'data-astro-injected': true, + }, + injectTo: 'head', + }); + } + }); + + // add injected tags + html = injectTags(html, tags); + + // run transformIndexHtml() in dev to run Vite dev transformations + if (mode === 'development' && !astroConfig.buildOptions.experimentalStaticBuild) { + const relativeURL = filePath.href.replace(astroConfig.projectRoot.href, '/'); + html = await viteServer.transformIndexHtml(relativeURL, html, pathname); + } + + // inject <!doctype html> if missing (TODO: is a more robust check needed for comments, etc.?) + if (!/<!doctype html/i.test(html)) { + html = '<!DOCTYPE html>\n' + html; + } + + return html; +} + +export async function ssr(ssrOpts: SSROptions): Promise<string> { + try { + const [renderers, mod] = await preload(ssrOpts); + return await render(renderers, mod, ssrOpts); // note(drew): without "await", errors won’t get caught by errorHandler() + } catch (e: unknown) { + await errorHandler(e, { viteServer: ssrOpts.viteServer, filePath: ssrOpts.filePath }); + throw e; + } +} diff --git a/packages/astro/src/core/render/dev/renderers.ts b/packages/astro/src/core/render/dev/renderers.ts new file mode 100644 index 000000000..abe22b3ca --- /dev/null +++ b/packages/astro/src/core/render/dev/renderers.ts @@ -0,0 +1,36 @@ +import type vite from '../../vite'; +import type { AstroConfig, Renderer } from '../../../@types/astro'; + +import { resolveDependency } from '../../util.js'; +import { createRenderer } from '../renderer.js'; + +const cache = new Map<string, Promise<Renderer>>(); + +async function resolveRenderer(viteServer: vite.ViteDevServer, renderer: string, astroConfig: AstroConfig): Promise<Renderer> { + const resolvedRenderer: Renderer = await createRenderer(renderer, { + renderer(name) { + return import(resolveDependency(name, astroConfig)); + }, + async server(entry) { + const { url } = await viteServer.moduleGraph.ensureEntryFromUrl(entry); + const mod = await viteServer.ssrLoadModule(url); + return mod; + } + }); + + return resolvedRenderer; +} + +export async function resolveRenderers(viteServer: vite.ViteDevServer, astroConfig: AstroConfig): Promise<Renderer[]> { + const ids: string[] = astroConfig.renderers; + const renderers = await Promise.all( + ids.map((renderer) => { + if (cache.has(renderer)) return cache.get(renderer)!; + let promise = resolveRenderer(viteServer, renderer, astroConfig); + cache.set(renderer, promise); + return promise; + }) + ); + + return renderers; +} diff --git a/packages/astro/src/core/ssr/paginate.ts b/packages/astro/src/core/render/paginate.ts index 96d8a435a..96d8a435a 100644 --- a/packages/astro/src/core/ssr/paginate.ts +++ b/packages/astro/src/core/render/paginate.ts diff --git a/packages/astro/src/core/render/renderer.ts b/packages/astro/src/core/render/renderer.ts new file mode 100644 index 000000000..42025cfc0 --- /dev/null +++ b/packages/astro/src/core/render/renderer.ts @@ -0,0 +1,30 @@ +import type { Renderer } from '../../@types/astro'; + +import npath from 'path'; + +interface RendererResolverImplementation { + renderer: (name: string) => Promise<any>; + server: (entry: string) => Promise<any>; +} + +export async function createRenderer(renderer: string, impl: RendererResolverImplementation) { + const resolvedRenderer: any = {}; + // We can dynamically import the renderer by itself because it shouldn't have + // any non-standard imports, the index is just meta info. + // The other entrypoints need to be loaded through Vite. + const { + default: { name, client, polyfills, hydrationPolyfills, server }, + } = await impl.renderer(renderer) //await import(resolveDependency(renderer, astroConfig)); + + resolvedRenderer.name = name; + if (client) resolvedRenderer.source = npath.posix.join(renderer, client); + resolvedRenderer.serverEntry = npath.posix.join(renderer, server); + if (Array.isArray(hydrationPolyfills)) resolvedRenderer.hydrationPolyfills = hydrationPolyfills.map((src: string) => npath.posix.join(renderer, src)); + if (Array.isArray(polyfills)) resolvedRenderer.polyfills = polyfills.map((src: string) => npath.posix.join(renderer, src)); + + const { default: rendererSSR } = await impl.server(resolvedRenderer.serverEntry); + resolvedRenderer.ssr = rendererSSR; + + const completedRenderer: Renderer = resolvedRenderer; + return completedRenderer; +} diff --git a/packages/astro/src/core/ssr/result.ts b/packages/astro/src/core/render/result.ts index 5a03ab769..9775a0949 100644 --- a/packages/astro/src/core/ssr/result.ts +++ b/packages/astro/src/core/render/result.ts @@ -1,25 +1,37 @@ -import type { AstroConfig, AstroGlobal, AstroGlobalPartial, Params, Renderer, SSRElement, SSRResult } from '../../@types/astro'; +import type { AstroGlobal, AstroGlobalPartial, MarkdownParser, MarkdownRenderOptions, Params, Renderer, SSRElement, SSRResult } from '../../@types/astro'; import { bold } from 'kleur/colors'; import { canonicalURL as getCanonicalURL } from '../util.js'; -import { isCSSRequest } from './css.js'; +import { isCSSRequest } from './dev/css.js'; import { isScriptRequest } from './script.js'; import { renderSlot } from '../../runtime/server/index.js'; import { warn, LogOptions } from '../logger.js'; export interface CreateResultArgs { - astroConfig: AstroConfig; + experimentalStaticBuild: boolean; logging: LogOptions; origin: string; + markdownRender: MarkdownRenderOptions; params: Params; pathname: string; renderers: Renderer[]; + resolve: (s: string) => Promise<string>; + site: string | undefined; links?: Set<SSRElement>; scripts?: Set<SSRElement>; } export function createResult(args: CreateResultArgs): SSRResult { - const { astroConfig, origin, params, pathname, renderers } = args; + const { + experimentalStaticBuild, + origin, + markdownRender, + params, + pathname, + renderers, + resolve, + site: buildOptionsSite + } = args; // Create the result object that will be passed into the render function. // This object starts here as an empty shell (not yet the result) but then @@ -32,7 +44,7 @@ export function createResult(args: CreateResultArgs): SSRResult { createAstro(astroGlobal: AstroGlobalPartial, props: Record<string, any>, slots: Record<string, any> | null) { const site = new URL(origin); const url = new URL('.' + pathname, site); - const canonicalURL = getCanonicalURL('.' + pathname, astroConfig.buildOptions.site || origin); + const canonicalURL = getCanonicalURL('.' + pathname, buildOptionsSite || origin); return { __proto__: astroGlobal, props, @@ -42,7 +54,7 @@ export function createResult(args: CreateResultArgs): SSRResult { url, }, resolve(path: string) { - if (astroConfig.buildOptions.experimentalStaticBuild) { + if (experimentalStaticBuild) { let extra = `This can be replaced with a dynamic import like so: await import("${path}")`; if (isCSSRequest(path)) { extra = `It looks like you are resolving styles. If you are adding a link tag, replace with this: @@ -83,33 +95,37 @@ ${extra}` }, // <Markdown> also needs the same `astroConfig.markdownOptions.render` as `.md` pages async privateRenderMarkdownDoNotUse(content: string, opts: any) { - let mdRender = astroConfig.markdownOptions.render; - let renderOpts = {}; + let [mdRender, renderOpts] = markdownRender; + let parser: MarkdownParser | null = null; + //let renderOpts = {}; if (Array.isArray(mdRender)) { renderOpts = mdRender[1]; mdRender = mdRender[0]; } // ['rehype-toc', opts] if (typeof mdRender === 'string') { - ({ default: mdRender } = await import(mdRender)); + const mod: { default: MarkdownParser } = await import(mdRender); + parser = mod.default; } // [import('rehype-toc'), opts] else if (mdRender instanceof Promise) { - ({ default: mdRender } = await mdRender); + const mod: { default: MarkdownParser } = await mdRender; + parser = mod.default; + } else if(typeof mdRender === 'function') { + parser = mdRender; + } else { + throw new Error('No Markdown parser found.'); } - const { code } = await mdRender(content, { ...renderOpts, ...(opts ?? {}) }); + const { code } = await parser(content, { ...renderOpts, ...(opts ?? {}) }); return code; }, } as unknown as AstroGlobal; }, - // This is a stub and will be implemented by dev and build. - async resolve(s: string): Promise<string> { - return ''; - }, + resolve, _metadata: { renderers, pathname, - experimentalStaticBuild: astroConfig.buildOptions.experimentalStaticBuild, + experimentalStaticBuild, }, }; diff --git a/packages/astro/src/core/ssr/route-cache.ts b/packages/astro/src/core/render/route-cache.ts index 11988d36b..889c64a48 100644 --- a/packages/astro/src/core/ssr/route-cache.ts +++ b/packages/astro/src/core/render/route-cache.ts @@ -1,8 +1,8 @@ import type { ComponentInstance, GetStaticPathsItem, GetStaticPathsResult, GetStaticPathsResultKeyed, RouteData, RSS } from '../../@types/astro'; import { LogOptions, warn, debug } from '../logger.js'; -import { generatePaginateFunction } from '../ssr/paginate.js'; -import { validateGetStaticPathsModule, validateGetStaticPathsResult } from './routing.js'; +import { generatePaginateFunction } from './paginate.js'; +import { validateGetStaticPathsModule, validateGetStaticPathsResult } from '../routing/index.js'; type RSSFn = (...args: any[]) => any; diff --git a/packages/astro/src/core/ssr/rss.ts b/packages/astro/src/core/render/rss.ts index 18cce36a1..1e77dff35 100644 --- a/packages/astro/src/core/ssr/rss.ts +++ b/packages/astro/src/core/render/rss.ts @@ -1,4 +1,4 @@ -import type { RSSFunction, RSS, RSSResult, FeedResult, RouteData } from '../../@types/astro'; +import type { RSSFunction, RSS, RSSResult, RouteData } from '../../@types/astro'; import { XMLValidator } from 'fast-xml-parser'; import { canonicalURL, isValidURL, PRETTY_FEED_V3 } from '../util.js'; diff --git a/packages/astro/src/core/ssr/script.ts b/packages/astro/src/core/render/script.ts index a91391963..a91391963 100644 --- a/packages/astro/src/core/ssr/script.ts +++ b/packages/astro/src/core/render/script.ts diff --git a/packages/astro/src/core/ssr/sitemap.ts b/packages/astro/src/core/render/sitemap.ts index a5ef54f6a..a5ef54f6a 100644 --- a/packages/astro/src/core/ssr/sitemap.ts +++ b/packages/astro/src/core/render/sitemap.ts diff --git a/packages/astro/src/core/render/ssr-element.ts b/packages/astro/src/core/render/ssr-element.ts new file mode 100644 index 000000000..5fbd3b115 --- /dev/null +++ b/packages/astro/src/core/render/ssr-element.ts @@ -0,0 +1,40 @@ +import type { SSRElement } from '../../@types/astro'; + +import npath from 'path'; +import { appendForwardSlash } from '../../core/path.js'; + +function getRootPath(site?: string): string { + return appendForwardSlash(new URL(site || 'http://localhost/').pathname) +} + +function joinToRoot(href: string, site?: string): string { + return npath.posix.join(getRootPath(site), href); +} + +export function createLinkStylesheetElement(href: string, site?: string): SSRElement { + return { + props: { + rel: 'stylesheet', + href: joinToRoot(href, site) + }, + children: '', + }; +} + +export function createLinkStylesheetElementSet(hrefs: string[], site?: string) { + return new Set<SSRElement>(hrefs.map(href => createLinkStylesheetElement(href, site))); +} + +export function createModuleScriptElementWithSrc(src: string, site?: string): SSRElement { + return { + props: { + type: 'module', + src: joinToRoot(src, site), + }, + children: '', + } +} + +export function createModuleScriptElementWithSrcSet(srces: string[], site?: string): Set<SSRElement> { + return new Set<SSRElement>(srces.map(src => createModuleScriptElementWithSrc(src, site))); +} diff --git a/packages/astro/src/core/routing/index.ts b/packages/astro/src/core/routing/index.ts new file mode 100644 index 000000000..2bc9be954 --- /dev/null +++ b/packages/astro/src/core/routing/index.ts @@ -0,0 +1,11 @@ +export { createRouteManifest } from './manifest/create.js'; +export { + serializeRouteData, + deserializeRouteData +} from './manifest/serialization.js'; +export { matchRoute } from './match.js'; +export { getParams } from './params.js'; +export { + validateGetStaticPathsModule, + validateGetStaticPathsResult +} from './validation.js'; diff --git a/packages/astro/src/core/ssr/routing.ts b/packages/astro/src/core/routing/manifest/create.ts index b6a2cf1a4..5456938ee 100644 --- a/packages/astro/src/core/ssr/routing.ts +++ b/packages/astro/src/core/routing/manifest/create.ts @@ -1,69 +1,16 @@ -import type { AstroConfig, ComponentInstance, GetStaticPathsResult, ManifestData, Params, RouteData } from '../../@types/astro'; -import type { LogOptions } from '../logger'; +import type { + AstroConfig, + ManifestData, + RouteData +} from '../../../@types/astro'; +import type { LogOptions } from '../../logger'; import fs from 'fs'; import path from 'path'; import { compile } from 'path-to-regexp'; import slash from 'slash'; import { fileURLToPath } from 'url'; -import { warn } from '../logger.js'; - -/** - * given an array of params like `['x', 'y', 'z']` for - * src/routes/[x]/[y]/[z]/svelte, create a function - * that turns a RegExpExecArray into ({ x, y, z }) - */ -export function getParams(array: string[]) { - const fn = (match: RegExpExecArray) => { - const params: Params = {}; - array.forEach((key, i) => { - if (key.startsWith('...')) { - params[key.slice(3)] = match[i + 1] ? decodeURIComponent(match[i + 1]) : undefined; - } else { - params[key] = decodeURIComponent(match[i + 1]); - } - }); - return params; - }; - - return fn; -} - -/** Find matching route from pathname */ -export function matchRoute(pathname: string, manifest: ManifestData): RouteData | undefined { - return manifest.routes.find((route) => route.pattern.test(pathname)); -} - -/** Throw error for deprecated/malformed APIs */ -export function validateGetStaticPathsModule(mod: ComponentInstance) { - if ((mod as any).createCollection) { - throw new Error(`[createCollection] deprecated. Please use getStaticPaths() instead.`); - } - if (!mod.getStaticPaths) { - throw new Error(`[getStaticPaths] getStaticPaths() function is required. Make sure that you \`export\` the function from your component.`); - } -} - -/** Throw error for malformed getStaticPaths() response */ -export function validateGetStaticPathsResult(result: GetStaticPathsResult, logging: LogOptions) { - if (!Array.isArray(result)) { - throw new Error(`[getStaticPaths] invalid return value. Expected an array of path objects, but got \`${JSON.stringify(result)}\`.`); - } - result.forEach((pathObject) => { - if (!pathObject.params) { - warn(logging, 'getStaticPaths', `invalid path object. Expected an object with key \`params\`, but got \`${JSON.stringify(pathObject)}\`. Skipped.`); - return; - } - for (const [key, val] of Object.entries(pathObject.params)) { - if (!(typeof val === 'undefined' || typeof val === 'string')) { - warn(logging, 'getStaticPaths', `invalid path param: ${key}. A string value was expected, but got \`${JSON.stringify(val)}\`.`); - } - if (val === '') { - warn(logging, 'getStaticPaths', `invalid path param: ${key}. \`undefined\` expected for an optional param, but got empty string.`); - } - } - }); -} +import { warn } from '../../logger.js'; interface Part { content: string; @@ -82,6 +29,148 @@ interface Item { routeSuffix: string; } +function countOccurrences(needle: string, haystack: string) { + let count = 0; + for (let i = 0; i < haystack.length; i += 1) { + if (haystack[i] === needle) count += 1; + } + return count; +} + +function getParts(part: string, file: string) { + const result: Part[] = []; + part.split(/\[(.+?\(.+?\)|.+?)\]/).map((str, i) => { + if (!str) return; + const dynamic = i % 2 === 1; + + const [, content] = dynamic ? /([^(]+)$/.exec(str) || [null, null] : [null, str]; + + if (!content || (dynamic && !/^(\.\.\.)?[a-zA-Z0-9_$]+$/.test(content))) { + throw new Error(`Invalid route ${file} — parameter name must match /^[a-zA-Z0-9_$]+$/`); + } + + result.push({ + content, + dynamic, + spread: dynamic && /^\.{3}.+$/.test(content), + }); + }); + + return result; +} + +function getPattern(segments: Part[][], addTrailingSlash: AstroConfig['devOptions']['trailingSlash']) { + const pathname = segments + .map((segment) => { + return segment[0].spread + ? '(?:\\/(.*?))?' + : '\\/' + + segment + .map((part) => { + if (part) + return part.dynamic + ? '([^/]+?)' + : part.content + .normalize() + .replace(/\?/g, '%3F') + .replace(/#/g, '%23') + .replace(/%5B/g, '[') + .replace(/%5D/g, ']') + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + }) + .join(''); + }) + .join(''); + + const trailing = addTrailingSlash && segments.length ? getTrailingSlashPattern(addTrailingSlash) : '$'; + return new RegExp(`^${pathname || '\\/'}${trailing}`); +} + + +function getTrailingSlashPattern(addTrailingSlash: AstroConfig['devOptions']['trailingSlash']): string { + if (addTrailingSlash === 'always') { + return '\\/$'; + } + if (addTrailingSlash === 'never') { + return '$'; + } + return '\\/?$'; +} + +function getGenerator(segments: Part[][], addTrailingSlash: AstroConfig['devOptions']['trailingSlash']) { + const template = segments + .map((segment) => { + return segment[0].spread + ? `/:${segment[0].content.substr(3)}(.*)?` + : '/' + + segment + .map((part) => { + if (part) + return part.dynamic + ? `:${part.content}` + : part.content + .normalize() + .replace(/\?/g, '%3F') + .replace(/#/g, '%23') + .replace(/%5B/g, '[') + .replace(/%5D/g, ']') + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + }) + .join(''); + }) + .join(''); + + const trailing = addTrailingSlash !== 'never' && segments.length ? '/' : ''; + const toPath = compile(template + trailing); + return toPath; +} + +function isSpread(str: string) { + const spreadPattern = /\[\.{3}/g; + return spreadPattern.test(str); +} + +function comparator(a: Item, b: Item) { + if (a.isIndex !== b.isIndex) { + if (a.isIndex) return isSpread(a.file) ? 1 : -1; + + return isSpread(b.file) ? -1 : 1; + } + + const max = Math.max(a.parts.length, b.parts.length); + + for (let i = 0; i < max; i += 1) { + const aSubPart = a.parts[i]; + const bSubPart = b.parts[i]; + + if (!aSubPart) return 1; // b is more specific, so goes first + if (!bSubPart) return -1; + + // if spread && index, order later + if (aSubPart.spread && bSubPart.spread) { + return a.isIndex ? 1 : -1; + } + + // If one is ...spread order it later + if (aSubPart.spread !== bSubPart.spread) return aSubPart.spread ? 1 : -1; + + if (aSubPart.dynamic !== bSubPart.dynamic) { + return aSubPart.dynamic ? 1 : -1; + } + + if (!aSubPart.dynamic && aSubPart.content !== bSubPart.content) { + return bSubPart.content.length - aSubPart.content.length || (aSubPart.content < bSubPart.content ? -1 : 1); + } + } + + if (a.isPage !== b.isPage) { + return a.isPage ? 1 : -1; + } + + // otherwise sort alphabetically + return a.file < b.file ? -1 : 1; +} + /** Create manifest of all static routes */ export function createRouteManifest({ config, cwd }: { config: AstroConfig; cwd?: string }, logging: LogOptions): ManifestData { const components: string[] = []; @@ -207,144 +296,3 @@ export function createRouteManifest({ config, cwd }: { config: AstroConfig; cwd? routes, }; } - -function countOccurrences(needle: string, haystack: string) { - let count = 0; - for (let i = 0; i < haystack.length; i += 1) { - if (haystack[i] === needle) count += 1; - } - return count; -} - -function isSpread(str: string) { - const spreadPattern = /\[\.{3}/g; - return spreadPattern.test(str); -} - -function comparator(a: Item, b: Item) { - if (a.isIndex !== b.isIndex) { - if (a.isIndex) return isSpread(a.file) ? 1 : -1; - - return isSpread(b.file) ? -1 : 1; - } - - const max = Math.max(a.parts.length, b.parts.length); - - for (let i = 0; i < max; i += 1) { - const aSubPart = a.parts[i]; - const bSubPart = b.parts[i]; - - if (!aSubPart) return 1; // b is more specific, so goes first - if (!bSubPart) return -1; - - // if spread && index, order later - if (aSubPart.spread && bSubPart.spread) { - return a.isIndex ? 1 : -1; - } - - // If one is ...spread order it later - if (aSubPart.spread !== bSubPart.spread) return aSubPart.spread ? 1 : -1; - - if (aSubPart.dynamic !== bSubPart.dynamic) { - return aSubPart.dynamic ? 1 : -1; - } - - if (!aSubPart.dynamic && aSubPart.content !== bSubPart.content) { - return bSubPart.content.length - aSubPart.content.length || (aSubPart.content < bSubPart.content ? -1 : 1); - } - } - - if (a.isPage !== b.isPage) { - return a.isPage ? 1 : -1; - } - - // otherwise sort alphabetically - return a.file < b.file ? -1 : 1; -} - -function getParts(part: string, file: string) { - const result: Part[] = []; - part.split(/\[(.+?\(.+?\)|.+?)\]/).map((str, i) => { - if (!str) return; - const dynamic = i % 2 === 1; - - const [, content] = dynamic ? /([^(]+)$/.exec(str) || [null, null] : [null, str]; - - if (!content || (dynamic && !/^(\.\.\.)?[a-zA-Z0-9_$]+$/.test(content))) { - throw new Error(`Invalid route ${file} — parameter name must match /^[a-zA-Z0-9_$]+$/`); - } - - result.push({ - content, - dynamic, - spread: dynamic && /^\.{3}.+$/.test(content), - }); - }); - - return result; -} - -function getTrailingSlashPattern(addTrailingSlash: AstroConfig['devOptions']['trailingSlash']): string { - if (addTrailingSlash === 'always') { - return '\\/$'; - } - if (addTrailingSlash === 'never') { - return '$'; - } - return '\\/?$'; -} - -function getPattern(segments: Part[][], addTrailingSlash: AstroConfig['devOptions']['trailingSlash']) { - const pathname = segments - .map((segment) => { - return segment[0].spread - ? '(?:\\/(.*?))?' - : '\\/' + - segment - .map((part) => { - if (part) - return part.dynamic - ? '([^/]+?)' - : part.content - .normalize() - .replace(/\?/g, '%3F') - .replace(/#/g, '%23') - .replace(/%5B/g, '[') - .replace(/%5D/g, ']') - .replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - }) - .join(''); - }) - .join(''); - - const trailing = addTrailingSlash && segments.length ? getTrailingSlashPattern(addTrailingSlash) : '$'; - return new RegExp(`^${pathname || '\\/'}${trailing}`); -} - -function getGenerator(segments: Part[][], addTrailingSlash: AstroConfig['devOptions']['trailingSlash']) { - const template = segments - .map((segment) => { - return segment[0].spread - ? `/:${segment[0].content.substr(3)}(.*)?` - : '/' + - segment - .map((part) => { - if (part) - return part.dynamic - ? `:${part.content}` - : part.content - .normalize() - .replace(/\?/g, '%3F') - .replace(/#/g, '%23') - .replace(/%5B/g, '[') - .replace(/%5D/g, ']') - .replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - }) - .join(''); - }) - .join(''); - - const trailing = addTrailingSlash !== 'never' && segments.length ? '/' : ''; - const toPath = compile(template + trailing); - return toPath; -} diff --git a/packages/astro/src/core/routing/manifest/serialization.ts b/packages/astro/src/core/routing/manifest/serialization.ts new file mode 100644 index 000000000..e751cc517 --- /dev/null +++ b/packages/astro/src/core/routing/manifest/serialization.ts @@ -0,0 +1,29 @@ +import type { + RouteData, + SerializedRouteData +} from '../../../@types/astro'; + +function createRouteData(pattern: RegExp, params: string[], component: string, pathname: string | undefined): RouteData { + return { + type: 'page', + pattern, + params, + component, + // TODO bring back + generate: () => '', + pathname: pathname || undefined, + } +} + +export function serializeRouteData(routeData: RouteData): SerializedRouteData { + // Is there a better way to do this in TypeScript? + const outRouteData = routeData as unknown as SerializedRouteData; + outRouteData.pattern = routeData.pattern.source; + return outRouteData; +} + +export function deserializeRouteData(rawRouteData: SerializedRouteData) { + const { component, params, pathname } = rawRouteData; + const pattern = new RegExp(rawRouteData.pattern); + return createRouteData(pattern, params, component, pathname); +} diff --git a/packages/astro/src/core/routing/match.ts b/packages/astro/src/core/routing/match.ts new file mode 100644 index 000000000..d5cf4e860 --- /dev/null +++ b/packages/astro/src/core/routing/match.ts @@ -0,0 +1,10 @@ +import type { + ManifestData, + RouteData +} from '../../@types/astro'; + +/** Find matching route from pathname */ +export function matchRoute(pathname: string, manifest: ManifestData): RouteData | undefined { + return manifest.routes.find((route) => route.pattern.test(pathname)); +} + diff --git a/packages/astro/src/core/routing/params.ts b/packages/astro/src/core/routing/params.ts new file mode 100644 index 000000000..739a99afd --- /dev/null +++ b/packages/astro/src/core/routing/params.ts @@ -0,0 +1,23 @@ +import type { Params } from '../../@types/astro'; + +/** + * given an array of params like `['x', 'y', 'z']` for + * src/routes/[x]/[y]/[z]/svelte, create a function + * that turns a RegExpExecArray into ({ x, y, z }) + */ + export function getParams(array: string[]) { + const fn = (match: RegExpExecArray) => { + const params: Params = {}; + array.forEach((key, i) => { + if (key.startsWith('...')) { + params[key.slice(3)] = match[i + 1] ? decodeURIComponent(match[i + 1]) : undefined; + } else { + params[key] = decodeURIComponent(match[i + 1]); + } + }); + return params; + }; + + return fn; +} + diff --git a/packages/astro/src/core/routing/validation.ts b/packages/astro/src/core/routing/validation.ts new file mode 100644 index 000000000..db47f6089 --- /dev/null +++ b/packages/astro/src/core/routing/validation.ts @@ -0,0 +1,37 @@ +import type { + ComponentInstance, + GetStaticPathsResult +} from '../../@types/astro'; +import type { LogOptions } from '../logger'; +import { warn } from '../logger.js'; + +/** Throw error for deprecated/malformed APIs */ +export function validateGetStaticPathsModule(mod: ComponentInstance) { + if ((mod as any).createCollection) { + throw new Error(`[createCollection] deprecated. Please use getStaticPaths() instead.`); + } + if (!mod.getStaticPaths) { + throw new Error(`[getStaticPaths] getStaticPaths() function is required. Make sure that you \`export\` the function from your component.`); + } +} + +/** Throw error for malformed getStaticPaths() response */ +export function validateGetStaticPathsResult(result: GetStaticPathsResult, logging: LogOptions) { + if (!Array.isArray(result)) { + throw new Error(`[getStaticPaths] invalid return value. Expected an array of path objects, but got \`${JSON.stringify(result)}\`.`); + } + result.forEach((pathObject) => { + if (!pathObject.params) { + warn(logging, 'getStaticPaths', `invalid path object. Expected an object with key \`params\`, but got \`${JSON.stringify(pathObject)}\`. Skipped.`); + return; + } + for (const [key, val] of Object.entries(pathObject.params)) { + if (!(typeof val === 'undefined' || typeof val === 'string')) { + warn(logging, 'getStaticPaths', `invalid path param: ${key}. A string value was expected, but got \`${JSON.stringify(val)}\`.`); + } + if (val === '') { + warn(logging, 'getStaticPaths', `invalid path param: ${key}. \`undefined\` expected for an optional param, but got empty string.`); + } + } + }); +} diff --git a/packages/astro/src/core/ssr/index.ts b/packages/astro/src/core/ssr/index.ts deleted file mode 100644 index c4d214a72..000000000 --- a/packages/astro/src/core/ssr/index.ts +++ /dev/null @@ -1,300 +0,0 @@ -import type { BuildResult } from 'esbuild'; -import type vite from '../vite'; -import type { AstroConfig, ComponentInstance, Params, Props, Renderer, RouteData, RuntimeMode, SSRElement, SSRError } from '../../@types/astro'; -import { LogOptions, warn } from '../logger.js'; - -import eol from 'eol'; -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import { renderPage } from '../../runtime/server/index.js'; -import { codeFrame, resolveDependency } from '../util.js'; -import { getStylesForURL } from './css.js'; -import { injectTags } from './html.js'; -import { getParams, validateGetStaticPathsResult } from './routing.js'; -import { createResult } from './result.js'; -import { callGetStaticPaths, findPathItemByKey, RouteCache } from './route-cache.js'; - -const svelteStylesRE = /svelte\?svelte&type=style/; - -interface SSROptions { - /** an instance of the AstroConfig */ - astroConfig: AstroConfig; - /** location of file on disk */ - filePath: URL; - /** logging options */ - logging: LogOptions; - /** "development" or "production" */ - mode: RuntimeMode; - /** production website, needed for some RSS & Sitemap functions */ - origin: string; - /** the web request (needed for dynamic routes) */ - pathname: string; - /** optional, in case we need to render something outside of a dev server */ - route?: RouteData; - /** pass in route cache because SSR can’t manage cache-busting */ - routeCache: RouteCache; - /** Vite instance */ - viteServer: vite.ViteDevServer; -} - -const cache = new Map<string, Promise<Renderer>>(); - -// TODO: improve validation and error handling here. -async function resolveRenderer(viteServer: vite.ViteDevServer, renderer: string, astroConfig: AstroConfig) { - const resolvedRenderer: any = {}; - // We can dynamically import the renderer by itself because it shouldn't have - // any non-standard imports, the index is just meta info. - // The other entrypoints need to be loaded through Vite. - const { - default: { name, client, polyfills, hydrationPolyfills, server }, - } = await import(resolveDependency(renderer, astroConfig)); - - resolvedRenderer.name = name; - if (client) resolvedRenderer.source = path.posix.join(renderer, client); - resolvedRenderer.serverEntry = path.posix.join(renderer, server); - if (Array.isArray(hydrationPolyfills)) resolvedRenderer.hydrationPolyfills = hydrationPolyfills.map((src: string) => path.posix.join(renderer, src)); - if (Array.isArray(polyfills)) resolvedRenderer.polyfills = polyfills.map((src: string) => path.posix.join(renderer, src)); - const { url } = await viteServer.moduleGraph.ensureEntryFromUrl(resolvedRenderer.serverEntry); - const { default: rendererSSR } = await viteServer.ssrLoadModule(url); - resolvedRenderer.ssr = rendererSSR; - - const completedRenderer: Renderer = resolvedRenderer; - return completedRenderer; -} - -async function resolveRenderers(viteServer: vite.ViteDevServer, astroConfig: AstroConfig): Promise<Renderer[]> { - const ids: string[] = astroConfig.renderers; - const renderers = await Promise.all( - ids.map((renderer) => { - if (cache.has(renderer)) return cache.get(renderer)!; - let promise = resolveRenderer(viteServer, renderer, astroConfig); - cache.set(renderer, promise); - return promise; - }) - ); - - return renderers; -} - -interface ErrorHandlerOptions { - filePath: URL; - viteServer: vite.ViteDevServer; -} - -async function errorHandler(e: unknown, { viteServer, filePath }: ErrorHandlerOptions) { - // normalize error stack line-endings to \n - if ((e as any).stack) { - (e as any).stack = eol.lf((e as any).stack); - } - - // fix stack trace with Vite (this searches its module graph for matches) - if (e instanceof Error) { - viteServer.ssrFixStacktrace(e); - } - - // Astro error (thrown by esbuild so it needs to be formatted for Vite) - if (Array.isArray((e as any).errors)) { - const { location, pluginName, text } = (e as BuildResult).errors[0]; - const err = e as SSRError; - if (location) err.loc = { file: location.file, line: location.line, column: location.column }; - let src = err.pluginCode; - if (!src && err.id && fs.existsSync(err.id)) src = await fs.promises.readFile(err.id, 'utf8'); - if (!src) src = await fs.promises.readFile(filePath, 'utf8'); - err.frame = codeFrame(src, err.loc); - err.id = location?.file; - err.message = `${location?.file}: ${text} -${err.frame} -`; - if (pluginName) err.plugin = pluginName; - throw err; - } - - // Generic error (probably from Vite, and already formatted) - throw e; -} - -export type ComponentPreload = [Renderer[], ComponentInstance]; - -export async function preload({ astroConfig, filePath, viteServer }: SSROptions): Promise<ComponentPreload> { - // Important: This needs to happen first, in case a renderer provides polyfills. - const renderers = await resolveRenderers(viteServer, astroConfig); - // Load the module from the Vite SSR Runtime. - const mod = (await viteServer.ssrLoadModule(fileURLToPath(filePath))) as ComponentInstance; - - return [renderers, mod]; -} - -export async function getParamsAndProps({ route, routeCache, pathname }: { route: RouteData | undefined; routeCache: RouteCache; pathname: string }): Promise<[Params, Props]> { - // Handle dynamic routes - let params: Params = {}; - let pageProps: Props; - if (route && !route.pathname) { - if (route.params.length) { - const paramsMatch = route.pattern.exec(pathname); - if (paramsMatch) { - params = getParams(route.params)(paramsMatch); - } - } - const routeCacheEntry = routeCache.get(route); - if (!routeCacheEntry) { - throw new Error(`[${route.component}] Internal error: route cache was empty, but expected to be full.`); - } - const paramsKey = JSON.stringify(params); - const matchedStaticPath = findPathItemByKey(routeCacheEntry.staticPaths, paramsKey); - if (!matchedStaticPath) { - throw new Error(`[getStaticPaths] route pattern matched, but no matching static path found. (${pathname})`); - } - // This is written this way for performance; instead of spreading the props - // which is O(n), create a new object that extends props. - pageProps = Object.create(matchedStaticPath.props || Object.prototype); - } else { - pageProps = {}; - } - return [params, pageProps]; -} - -/** use Vite to SSR */ -export async function render(renderers: Renderer[], mod: ComponentInstance, ssrOpts: SSROptions): Promise<string> { - const { astroConfig, filePath, logging, mode, origin, pathname, route, routeCache, viteServer } = ssrOpts; - - // Handle dynamic routes - let params: Params = {}; - let pageProps: Props = {}; - if (route && !route.pathname) { - if (route.params.length) { - const paramsMatch = route.pattern.exec(pathname); - if (paramsMatch) { - params = getParams(route.params)(paramsMatch); - } - } - let routeCacheEntry = routeCache.get(route); - // TODO(fks): All of our getStaticPaths logic should live in a single place, - // to prevent duplicate runs during the build. This is not expected to run - // anymore and we should change this check to thrown an internal error. - if (!routeCacheEntry) { - warn(logging, 'routeCache', `Internal Warning: getStaticPaths() called twice during the build. (${route.component})`); - routeCacheEntry = await callGetStaticPaths(mod, route, true, logging); - routeCache.set(route, routeCacheEntry); - } - const matchedStaticPath = routeCacheEntry.staticPaths.find(({ params: _params }) => JSON.stringify(_params) === JSON.stringify(params)); - if (!matchedStaticPath) { - throw new Error(`[getStaticPaths] route pattern matched, but no matching static path found. (${pathname})`); - } - pageProps = { ...matchedStaticPath.props } || {}; - } - - // Validate the page component before rendering the page - const Component = await mod.default; - if (!Component) throw new Error(`Expected an exported Astro component but received typeof ${typeof Component}`); - if (!Component.isAstroComponentFactory) throw new Error(`Unable to SSR non-Astro component (${route?.component})`); - - // Add hoisted script tags - const scripts = astroConfig.buildOptions.experimentalStaticBuild - ? new Set<SSRElement>( - Array.from(mod.$$metadata.hoistedScriptPaths()).map((src) => ({ - props: { type: 'module', src }, - children: '', - })) - ) - : new Set<SSRElement>(); - - // Inject HMR scripts - if (mode === 'development' && astroConfig.buildOptions.experimentalStaticBuild) { - scripts.add({ - props: { type: 'module', src: '/@vite/client' }, - children: '', - }); - scripts.add({ - props: { type: 'module', src: new URL('../../runtime/client/hmr.js', import.meta.url).pathname }, - children: '', - }); - } - - const result = createResult({ astroConfig, logging, origin, params, pathname, renderers, scripts }); - // Resolves specifiers in the inline hydrated scripts, such as "@astrojs/renderer-preact/client.js" - result.resolve = async (s: string) => { - // The legacy build needs these to remain unresolved so that vite HTML - // Can do the resolution. Without this condition the build output will be - // broken in the legacy build. This can be removed once the legacy build is removed. - if (astroConfig.buildOptions.experimentalStaticBuild) { - const [, resolvedPath] = await viteServer.moduleGraph.resolveUrl(s); - return resolvedPath; - } else { - return s; - } - }; - - let html = await renderPage(result, Component, pageProps, null); - - // inject tags - const tags: vite.HtmlTagDescriptor[] = []; - - // dev only: inject Astro HMR client - if (mode === 'development' && !astroConfig.buildOptions.experimentalStaticBuild) { - tags.push({ - tag: 'script', - attrs: { type: 'module' }, - // HACK: inject the direct contents of our `astro/runtime/client/hmr.js` to ensure - // `import.meta.hot` is properly handled by Vite - children: await getHmrScript(), - injectTo: 'head', - }); - } - - // inject CSS - [...getStylesForURL(filePath, viteServer)].forEach((href) => { - if (mode === 'development' && svelteStylesRE.test(href)) { - tags.push({ - tag: 'script', - attrs: { type: 'module', src: href }, - injectTo: 'head', - }); - } else { - tags.push({ - tag: 'link', - attrs: { - rel: 'stylesheet', - href, - 'data-astro-injected': true, - }, - injectTo: 'head', - }); - } - }); - - // add injected tags - html = injectTags(html, tags); - - // run transformIndexHtml() in dev to run Vite dev transformations - if (mode === 'development' && !astroConfig.buildOptions.experimentalStaticBuild) { - const relativeURL = filePath.href.replace(astroConfig.projectRoot.href, '/'); - html = await viteServer.transformIndexHtml(relativeURL, html, pathname); - } - - // inject <!doctype html> if missing (TODO: is a more robust check needed for comments, etc.?) - if (!/<!doctype html/i.test(html)) { - html = '<!DOCTYPE html>\n' + html; - } - - return html; -} - -let hmrScript: string; -async function getHmrScript() { - if (hmrScript) return hmrScript; - const filePath = fileURLToPath(new URL('../../runtime/client/hmr.js', import.meta.url)); - const content = await fs.promises.readFile(filePath); - hmrScript = content.toString(); - return hmrScript; -} - -export async function ssr(ssrOpts: SSROptions): Promise<string> { - try { - const [renderers, mod] = await preload(ssrOpts); - return await render(renderers, mod, ssrOpts); // note(drew): without "await", errors won’t get caught by errorHandler() - } catch (e: unknown) { - await errorHandler(e, { viteServer: ssrOpts.viteServer, filePath: ssrOpts.filePath }); - throw e; - } -} diff --git a/packages/astro/src/runtime/server/index.ts b/packages/astro/src/runtime/server/index.ts index 93c908416..e987db5f6 100644 --- a/packages/astro/src/runtime/server/index.ts +++ b/packages/astro/src/runtime/server/index.ts @@ -157,7 +157,7 @@ export async function renderComponent(result: SSRResult, displayName: string, Co } const probableRendererNames = guessRenderers(metadata.componentUrl); - if (Array.isArray(renderers) && renderers.length === 0 && typeof Component !== 'string' && !HTMLElement.isPrototypeOf(Component as object)) { + if (Array.isArray(renderers) && renderers.length === 0 && typeof Component !== 'string' && !componentIsHTMLElement(Component)) { const message = `Unable to render ${metadata.displayName}! There are no \`renderers\` set in your \`astro.config.mjs\` file. @@ -175,7 +175,7 @@ Did you mean to enable ${formatList(probableRendererNames.map((r) => '`' + r + ' } } - if (!renderer && HTMLElement.isPrototypeOf(Component as object)) { + if (!renderer && typeof HTMLElement === 'function' && componentIsHTMLElement(Component)) { const output = renderHTMLElement(result, Component as typeof HTMLElement, _props, slots); return output; @@ -465,6 +465,10 @@ export async function renderAstroComponent(component: InstanceType<typeof AstroC return unescapeHTML(await _render(template)); } +function componentIsHTMLElement(Component: unknown) { + return typeof HTMLElement !== 'undefined' && HTMLElement.isPrototypeOf(Component as object); +} + export async function renderHTMLElement(result: SSRResult, constructor: typeof HTMLElement, props: any, slots: any) { const name = getHTMLElementName(constructor); diff --git a/packages/astro/src/vite-plugin-astro-server/index.ts b/packages/astro/src/vite-plugin-astro-server/index.ts index de57e1593..eb08ac8a0 100644 --- a/packages/astro/src/vite-plugin-astro-server/index.ts +++ b/packages/astro/src/vite-plugin-astro-server/index.ts @@ -2,17 +2,16 @@ import type vite from '../core/vite'; import type http from 'http'; import type { AstroConfig, ManifestData, RouteData } from '../@types/astro'; import { info, LogOptions } from '../core/logger.js'; -import { fileURLToPath } from 'url'; -import { createRouteManifest, matchRoute } from '../core/ssr/routing.js'; +import { createRouteManifest, matchRoute } from '../core/routing/index.js'; import mime from 'mime'; import stripAnsi from 'strip-ansi'; import { createSafeError } from '../core/util.js'; -import { ssr } from '../core/ssr/index.js'; +import { ssr } from '../core/render/dev/index.js'; import * as msg from '../core/messages.js'; import notFoundTemplate, { subpathNotUsedTemplate } from '../template/4xx.js'; import serverErrorTemplate from '../template/5xx.js'; -import { RouteCache } from '../core/ssr/route-cache.js'; +import { RouteCache } from '../core/render/route-cache.js'; interface AstroPluginOptions { config: AstroConfig; @@ -126,7 +125,6 @@ export default function createPlugin({ config, logging }: AstroPluginOptions): v return { name: 'astro:server', configureServer(viteServer) { - const pagesDirectory = fileURLToPath(config.pages); let routeCache = new RouteCache(logging); let manifest: ManifestData = createRouteManifest({ config: config }, logging); /** rebuild the route cache + manifest, as needed. */ diff --git a/packages/astro/src/vite-plugin-astro/styles.ts b/packages/astro/src/vite-plugin-astro/styles.ts index 6ebcd0e0d..b49ce6e9b 100644 --- a/packages/astro/src/vite-plugin-astro/styles.ts +++ b/packages/astro/src/vite-plugin-astro/styles.ts @@ -1,6 +1,6 @@ import type vite from '../core/vite'; -import { STYLE_EXTENSIONS } from '../core/ssr/css.js'; +import { STYLE_EXTENSIONS } from '../core/render/dev/css.js'; export type TransformHook = (code: string, id: string, ssr?: boolean) => Promise<vite.TransformResult>; diff --git a/packages/astro/src/vite-plugin-build-css/index.ts b/packages/astro/src/vite-plugin-build-css/index.ts index 155fdc8ed..de7933f7d 100644 --- a/packages/astro/src/vite-plugin-build-css/index.ts +++ b/packages/astro/src/vite-plugin-build-css/index.ts @@ -1,10 +1,9 @@ -import type { RenderedChunk } from 'rollup'; import type { BuildInternals } from '../core/build/internal'; import * as path from 'path'; import esbuild from 'esbuild'; import { Plugin as VitePlugin } from '../core/vite'; -import { isCSSRequest } from '../core/ssr/css.js'; +import { isCSSRequest } from '../core/render/dev/css.js'; const PLUGIN_NAME = '@astrojs/rollup-plugin-build-css'; diff --git a/packages/astro/src/vite-plugin-build-html/index.ts b/packages/astro/src/vite-plugin-build-html/index.ts index 48bb617c9..87cc46779 100644 --- a/packages/astro/src/vite-plugin-build-html/index.ts +++ b/packages/astro/src/vite-plugin-build-html/index.ts @@ -12,10 +12,10 @@ import { getAttribute, hasAttribute, insertBefore, remove, createScript, createE import { addRollupInput } from './add-rollup-input.js'; import { findAssets, findExternalScripts, findInlineScripts, findInlineStyles, getTextContent, getAttributes } from './extract-assets.js'; import { isBuildableImage, isBuildableLink, isHoistedScript, isInSrcDirectory, hasSrcSet } from './util.js'; -import { render as ssrRender } from '../core/ssr/index.js'; +import { render as ssrRender } from '../core/render/dev/index.js'; import { getAstroStyleId, getAstroPageStyleId } from '../vite-plugin-build-css/index.js'; import { prependDotSlash, removeEndingForwardSlash } from '../core/path.js'; -import { RouteCache } from '../core/ssr/route-cache.js'; +import { RouteCache } from '../core/render/route-cache.js'; // This package isn't real ESM, so have to coerce it const matchSrcset: typeof srcsetParse = (srcsetParse as any).default; diff --git a/packages/astro/test/route-manifest.test.js b/packages/astro/test/route-manifest.test.js index 730bc72b4..2432b0327 100644 --- a/packages/astro/test/route-manifest.test.js +++ b/packages/astro/test/route-manifest.test.js @@ -1,6 +1,6 @@ import { expect } from 'chai'; import { fileURLToPath } from 'url'; -import { createRouteManifest } from '../dist/core/ssr/routing.js'; +import { createRouteManifest } from '../dist/core/routing/index.js'; const cwd = new URL('./fixtures/route-manifest/', import.meta.url); diff --git a/scripts/smoke/index.js b/scripts/smoke/index.js index 8c3f27ae9..8a0cf2242 100644 --- a/scripts/smoke/index.js +++ b/scripts/smoke/index.js @@ -3,6 +3,7 @@ import fs from 'fs'; import { execa } from 'execa'; import { fileURLToPath } from 'url'; +import path from 'path'; // NOTE: Only needed for Windows, due to a Turbo bug. // Once Turbo works on Windows, we can remove this script @@ -160,6 +160,11 @@ resolved "https://registry.yarnpkg.com/@astropub/webapi/-/webapi-0.10.11.tgz#7cf3926bb24f474b344025fa437d08fa71d0a6b9" integrity sha512-i1Aw6Px3n+x0GGbZxoQc6bY2gxks//rPUwuX4ICuNai8GvK/6j/0OGMVkD3ZlVJ/zfUtds8BDx/k+TJYaAPlKQ== +"@astropub/webapi@^0.10.13": + version "0.10.13" + resolved "https://registry.yarnpkg.com/@astropub/webapi/-/webapi-0.10.13.tgz#28f95706d1e9041495347465fb347eb78035cdd7" + integrity sha512-efUVnq9IWPHYl5nxSLkDZzp1RvNmKpYApcHhgQnN2A+4D8z6dnTYlXo5Ogl0aAJWMMBKN89Q2GJwDF0zy8Lonw== + "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.16.7": version "7.16.7" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.16.7.tgz#44416b6bd7624b998f5b1af5d470856c40138789" @@ -6572,16 +6577,11 @@ preact-render-to-string@^5.1.19: dependencies: pretty-format "^3.8.0" -preact@^10.6.5: +preact@^10.6.5, preact@~10.6.5: version "10.6.5" resolved "https://registry.yarnpkg.com/preact/-/preact-10.6.5.tgz#726d8bd12903a0d51cdd17e2e1b90cc539403e0c" integrity sha512-i+LXM6JiVjQXSt2jG2vZZFapGpCuk1fl8o6ii3G84MA3xgj686FKjs4JFDkmUVhtxyq21+4ay74zqPykz9hU6w== -preact@~10.5.15: - version "10.5.15" - resolved "https://registry.yarnpkg.com/preact/-/preact-10.5.15.tgz#6df94d8afecf3f9e10a742fd8c362ddab464225f" - integrity sha512-5chK29n6QcJc3m1lVrKQSQ+V7K1Gb8HeQY6FViQ5AxCAEGu3DaHffWNDkC9+miZgsLvbvU9rxbV1qinGHMHzqA== - prebuild-install@^7.0.0: version "7.0.1" resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.0.1.tgz#c10075727c318efe72412f333e0ef625beaf3870" |