Implements redirects, headers for SSR (#2798)
* Implements redirects, headers for SSR * Move away from an explicit Request * Properly handle endpoint routes in the build * chore(lint): ESLint fix * Update based on review comments Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
8f13b3d406
commit
4c25a1c2ea
33 changed files with 698 additions and 109 deletions
5
.changeset/fuzzy-lies-nail.md
Normal file
5
.changeset/fuzzy-lies-nail.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'astro': patch
|
||||
---
|
||||
|
||||
Implement APIs for headers for SSR flag
|
|
@ -4,8 +4,14 @@ export default defineConfig({
|
|||
renderers: ['@astrojs/renderer-svelte'],
|
||||
vite: {
|
||||
server: {
|
||||
cors: {
|
||||
credentials: true
|
||||
},
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8085',
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8085',
|
||||
changeOrigin: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -4,7 +4,8 @@
|
|||
"private": true,
|
||||
"scripts": {
|
||||
"dev-api": "node server/dev-api.mjs",
|
||||
"dev": "npm run dev-api & astro dev --experimental-ssr",
|
||||
"dev-server": "astro dev --experimental-ssr",
|
||||
"dev": "concurrently \"npm run dev-api\" \"astro dev --experimental-ssr\"",
|
||||
"start": "astro dev",
|
||||
"build": "echo 'Run pnpm run build-ssr instead'",
|
||||
"build-ssr": "node build.mjs",
|
||||
|
@ -13,6 +14,8 @@
|
|||
"devDependencies": {
|
||||
"@astrojs/renderer-svelte": "^0.5.2",
|
||||
"astro": "^0.24.3",
|
||||
"concurrently": "^7.0.0",
|
||||
"lightcookie": "^1.0.25",
|
||||
"unocss": "^0.15.6",
|
||||
"vite-imagetools": "^4.0.3"
|
||||
}
|
||||
|
|
|
@ -1,9 +1,14 @@
|
|||
import fs from 'fs';
|
||||
import lightcookie from 'lightcookie';
|
||||
|
||||
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]));
|
||||
|
||||
// Normally this would be in a database.
|
||||
const userCartItems = new Map();
|
||||
|
||||
const routes = [
|
||||
{
|
||||
match: /\/api\/products\/([0-9])+/,
|
||||
|
@ -32,6 +37,53 @@ const routes = [
|
|||
res.end(JSON.stringify(products));
|
||||
},
|
||||
},
|
||||
{
|
||||
match: /\/api\/cart/,
|
||||
async handle(req, res) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json'
|
||||
});
|
||||
let cookie = req.headers.cookie;
|
||||
let userId = cookie ? lightcookie.parse(cookie)['user-id'] : '1'; // default for testing
|
||||
if(!userId || !userCartItems.has(userId)) {
|
||||
res.end(JSON.stringify({ items: [] }));
|
||||
return;
|
||||
}
|
||||
let items = userCartItems.get(userId);
|
||||
let array = Array.from(items.values());
|
||||
res.end(JSON.stringify({ items: array }));
|
||||
}
|
||||
},
|
||||
{
|
||||
match: /\/api\/add-to-cart/,
|
||||
async handle(req, res) {
|
||||
let body = '';
|
||||
req.on('data', chunk => body += chunk);
|
||||
return new Promise(resolve => {
|
||||
req.on('end', () => {
|
||||
let cookie = req.headers.cookie;
|
||||
let userId = lightcookie.parse(cookie)['user-id'];
|
||||
let msg = JSON.parse(body);
|
||||
|
||||
if(!userCartItems.has(userId)) {
|
||||
userCartItems.set(userId, new Map());
|
||||
}
|
||||
|
||||
let cart = userCartItems.get(userId);
|
||||
if(cart.has(msg.id)) {
|
||||
cart.get(msg.id).count++;
|
||||
} else {
|
||||
cart.set(msg.id, { id: msg.id, name: msg.name, count: 1 });
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export async function apiHandler(req, res) {
|
||||
|
|
|
@ -15,9 +15,10 @@ async function handle(req, res) {
|
|||
const route = app.match(req);
|
||||
|
||||
if (route) {
|
||||
const html = await app.render(req, route);
|
||||
|
||||
res.writeHead(200, {
|
||||
/** @type {Response} */
|
||||
const response = await app.render(req, route);
|
||||
const html = await response.text();
|
||||
res.writeHead(response.status, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(html, 'utf-8'),
|
||||
});
|
||||
|
|
|
@ -5,12 +5,25 @@ interface Product {
|
|||
image: string;
|
||||
}
|
||||
|
||||
//let origin: string;
|
||||
const { mode } = import.meta.env;
|
||||
const origin = mode === 'develepment' ? `http://localhost:3000` : `http://localhost:8085`;
|
||||
interface User {
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface Cart {
|
||||
items: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
count: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
const { MODE } = import.meta.env;
|
||||
const origin = MODE === 'development' ? `http://127.0.0.1:3000` : `http://127.0.0.1:8085`;
|
||||
|
||||
async function get<T>(endpoint: string, cb: (response: Response) => Promise<T>): Promise<T> {
|
||||
const response = await fetch(`${origin}${endpoint}`);
|
||||
const response = await fetch(`${origin}${endpoint}`, {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (!response.ok) {
|
||||
// TODO make this better...
|
||||
return null;
|
||||
|
@ -31,3 +44,33 @@ export async function getProduct(id: number): Promise<Product> {
|
|||
return product;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUser(): Promise<User> {
|
||||
return get<User>(`/api/user`, async response => {
|
||||
const user: User = await response.json();
|
||||
return user;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCart(): Promise<Cart> {
|
||||
return get<Cart>(`/api/cart`, async response => {
|
||||
const cart: Cart = await response.json();
|
||||
return cart;
|
||||
});
|
||||
}
|
||||
|
||||
export async function addToUserCart(id: number | string, name: string): Promise<void> {
|
||||
await fetch(`${origin}/api/add-to-cart`, {
|
||||
credentials: 'same-origin',
|
||||
method: 'POST',
|
||||
mode: 'no-cors',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache': 'no-cache'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
name
|
||||
})
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,11 +1,18 @@
|
|||
<script>
|
||||
import { addToUserCart } from '../api';
|
||||
export let id = 0;
|
||||
export let name = '';
|
||||
|
||||
function addToCart() {
|
||||
function notifyCartItem(id) {
|
||||
window.dispatchEvent(new CustomEvent('add-to-cart', {
|
||||
detail: id
|
||||
}));
|
||||
}
|
||||
|
||||
async function addToCart() {
|
||||
await addToUserCart(id, name);
|
||||
notifyCartItem(id);
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
button {
|
||||
|
|
|
@ -12,6 +12,8 @@
|
|||
.cart {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.cart :first-child {
|
||||
margin-right: 5px;
|
||||
|
@ -26,7 +28,7 @@
|
|||
}
|
||||
</style>
|
||||
<svelte:window on:add-to-cart={onAddToCart}/>
|
||||
<div class="cart">
|
||||
<a href="/cart" class="cart">
|
||||
<span class="material-icons cart-icon">shopping_cart</span>
|
||||
<span class="count">{count}</span>
|
||||
</div>
|
||||
</a>
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
---
|
||||
import TextDecorationSkip from './TextDecorationSkip.astro';
|
||||
import Cart from './Cart.svelte';
|
||||
import { getCart } from '../api';
|
||||
|
||||
const cart = await getCart();
|
||||
const cartCount = cart.items.reduce((sum, item) => sum + item.count, 0);
|
||||
---
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Lobster&display=swap');
|
||||
|
@ -21,11 +25,25 @@ import Cart from './Cart.svelte';
|
|||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.right-pane {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-size: 36px;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
</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 />
|
||||
<a href="/login">
|
||||
<span class="material-icons">
|
||||
login
|
||||
</span>
|
||||
</a>
|
||||
<Cart client:idle count={cartCount} />
|
||||
</div>
|
||||
</header>
|
||||
|
|
8
examples/ssr/src/models/user.ts
Normal file
8
examples/ssr/src/models/user.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import lightcookie from 'lightcookie';
|
||||
|
||||
|
||||
export function isLoggedIn(request: Request): boolean {
|
||||
const cookie = request.headers.get('cookie');
|
||||
const parsed = lightcookie.parse(cookie);
|
||||
return 'user-id' in parsed;
|
||||
}
|
47
examples/ssr/src/pages/cart.astro
Normal file
47
examples/ssr/src/pages/cart.astro
Normal file
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
import Header from '../components/Header.astro';
|
||||
import Container from '../components/Container.astro';
|
||||
import { getCart } from '../api';
|
||||
import { isLoggedIn } from '../models/user';
|
||||
|
||||
if(!isLoggedIn(Astro.request)) {
|
||||
return Astro.redirect('/');
|
||||
}
|
||||
|
||||
// They must be logged in.
|
||||
|
||||
const user = { name: 'test'}; // getUser?
|
||||
const cart = await getCart();
|
||||
---
|
||||
<html>
|
||||
<head>
|
||||
<title>Cart | Online Store</title>
|
||||
<style>
|
||||
h1 {
|
||||
font-size: 36px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<Header />
|
||||
|
||||
<Container tag="main">
|
||||
<h1>Cart</h1>
|
||||
<p>Hi { user.name }! Here are your cart items:</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<th>Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cart.items.map(item => <tr>
|
||||
<td>{item.name}</td>
|
||||
<td>{item.count}</td>
|
||||
</tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Container>
|
||||
</body>
|
||||
</html>
|
30
examples/ssr/src/pages/login.astro
Normal file
30
examples/ssr/src/pages/login.astro
Normal file
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
import Header from '../components/Header.astro';
|
||||
import Container from '../components/Container.astro';
|
||||
---
|
||||
<html>
|
||||
<head>
|
||||
<title>Online Store</title>
|
||||
<style>
|
||||
h1 {
|
||||
font-size: 36px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<Header />
|
||||
|
||||
<Container tag="main">
|
||||
<h1>Login</h1>
|
||||
<form action="/login.form" method="POST">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" name="name">
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input type="password" name="password">
|
||||
|
||||
<input type="submit" value="Submit">
|
||||
</form>
|
||||
</Container>
|
||||
</body>
|
||||
</html>
|
10
examples/ssr/src/pages/login.form.js
Normal file
10
examples/ssr/src/pages/login.form.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
|
||||
export function post(params, request) {
|
||||
return new Response(null, {
|
||||
status: 301,
|
||||
headers: {
|
||||
'Location': '/',
|
||||
'Set-Cookie': 'user-id=1; Path=/; Max-Age=2592000'
|
||||
}
|
||||
});
|
||||
}
|
|
@ -45,7 +45,7 @@ const product = await getProduct(id);
|
|||
<figure>
|
||||
<img src={product.image} />
|
||||
<figcaption>
|
||||
<AddToCart id={id} client:idle />
|
||||
<AddToCart client:idle id={id} name={product.name} />
|
||||
<p>Description here...</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
|
8
examples/ssr/tsconfig.json
Normal file
8
examples/ssr/tsconfig.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2015", "DOM"],
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "node",
|
||||
"types": ["astro/env"]
|
||||
}
|
||||
}
|
|
@ -2,6 +2,7 @@ import type * as babel from '@babel/core';
|
|||
import type { z } from 'zod';
|
||||
import type { AstroConfigSchema } from '../core/config';
|
||||
import type { AstroComponentFactory, Metadata } from '../runtime/server';
|
||||
import type { AstroRequest } from '../core/render/request';
|
||||
import type * as vite from 'vite';
|
||||
|
||||
export interface AstroBuiltinProps {
|
||||
|
@ -43,14 +44,7 @@ export interface AstroGlobal extends AstroGlobalPartial {
|
|||
/** set props for this astro component (along with default values) */
|
||||
props: Record<string, number | string | any>;
|
||||
/** get information about this page */
|
||||
request: {
|
||||
/** get the current page URL */
|
||||
url: URL;
|
||||
/** get the current canonical URL */
|
||||
canonicalURL: URL;
|
||||
/** get page params (dynamic pages only) */
|
||||
params: Params;
|
||||
};
|
||||
request: AstroRequest;
|
||||
/** see if slots are used */
|
||||
slots: Record<string, true | undefined> & { has(slotName: string): boolean; render(slotName: string): Promise<string> };
|
||||
}
|
||||
|
@ -563,7 +557,7 @@ export interface EndpointOutput<Output extends Body = Body> {
|
|||
}
|
||||
|
||||
export interface EndpointHandler {
|
||||
[method: string]: (params: any) => EndpointOutput;
|
||||
[method: string]: (params: any, request: AstroRequest) => EndpointOutput | Response;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -27,14 +27,18 @@ export class App {
|
|||
this.#routeCache = new RouteCache(defaultLogOptions);
|
||||
this.#renderersPromise = this.#loadRenderers();
|
||||
}
|
||||
match({ pathname }: URL): RouteData | undefined {
|
||||
return matchRoute(pathname, this.#manifestData);
|
||||
match(request: Request): RouteData | undefined {
|
||||
const url = new URL(request.url);
|
||||
return matchRoute(url.pathname, this.#manifestData);
|
||||
}
|
||||
async render(url: URL, routeData?: RouteData): Promise<string> {
|
||||
async render(request: Request, routeData?: RouteData): Promise<Response> {
|
||||
if (!routeData) {
|
||||
routeData = this.match(url);
|
||||
routeData = this.match(request);
|
||||
if (!routeData) {
|
||||
return 'Not found';
|
||||
return new Response(null, {
|
||||
status: 404,
|
||||
statusText: 'Not found'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -42,10 +46,11 @@ export class App {
|
|||
const info = this.#routeDataToRouteInfo.get(routeData!)!;
|
||||
const [mod, renderers] = await Promise.all([this.#loadModule(info.file), this.#renderersPromise]);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const links = createLinkStylesheetElementSet(info.links, manifest.site);
|
||||
const scripts = createModuleScriptElementWithSrcSet(info.scripts, manifest.site);
|
||||
|
||||
return render({
|
||||
const result = await render({
|
||||
legacyBuild: false,
|
||||
links,
|
||||
logging: defaultLogOptions,
|
||||
|
@ -65,6 +70,18 @@ export class App {
|
|||
route: routeData,
|
||||
routeCache: this.#routeCache,
|
||||
site: this.#manifest.site,
|
||||
ssr: true,
|
||||
method: info.routeData.type === 'endpoint' ? '' : 'GET',
|
||||
headers: request.headers,
|
||||
});
|
||||
|
||||
if(result.type === 'response') {
|
||||
return result.response;
|
||||
}
|
||||
|
||||
let html = result.html;
|
||||
return new Response(html, {
|
||||
status: 200
|
||||
});
|
||||
}
|
||||
async #loadRenderers(): Promise<Renderer[]> {
|
||||
|
|
|
@ -1,20 +1,27 @@
|
|||
import type { SSRManifest, SerializedSSRManifest } from './types';
|
||||
import type { IncomingHttpHeaders } from 'http';
|
||||
|
||||
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}`);
|
||||
function createRequestFromNodeRequest(req: IncomingMessage): Request {
|
||||
let url = `http://${req.headers.host}${req.url}`;
|
||||
const entries = Object.entries(req.headers as Record<string, any>);
|
||||
let request = new Request(url, {
|
||||
method: req.method || 'GET',
|
||||
headers: new Headers(entries)
|
||||
});
|
||||
return request;
|
||||
}
|
||||
|
||||
class NodeApp extends App {
|
||||
match(req: IncomingMessage | URL) {
|
||||
return super.match(req instanceof URL ? req : createURLFromRequest(req));
|
||||
match(req: IncomingMessage | Request) {
|
||||
return super.match(req instanceof Request ? req : createRequestFromNodeRequest(req));
|
||||
}
|
||||
render(req: IncomingMessage | URL) {
|
||||
return super.render(req instanceof URL ? req : createURLFromRequest(req));
|
||||
render(req: IncomingMessage | Request) {
|
||||
return super.render(req instanceof Request ? req : createRequestFromNodeRequest(req));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ import type { LogOptions } from '../logger';
|
|||
|
||||
import fs from 'fs';
|
||||
import * as colors from 'kleur/colors';
|
||||
import { polyfill } from '@astrojs/webapi';
|
||||
import { apply as applyPolyfill } from '../polyfill.js';
|
||||
import { performance } from 'perf_hooks';
|
||||
import * as vite from 'vite';
|
||||
import { createVite, ViteConfigWithSSR } from '../create-vite.js';
|
||||
|
@ -22,11 +22,6 @@ export interface BuildOptions {
|
|||
|
||||
/** `astro build` */
|
||||
export default async function build(config: AstroConfig, options: BuildOptions = { logging: defaultLogOptions }): Promise<void> {
|
||||
// polyfill WebAPIs to globalThis for Node v12, Node v14, and Node v16
|
||||
polyfill(globalThis, {
|
||||
exclude: 'window document',
|
||||
});
|
||||
|
||||
const builder = new AstroBuilder(config, options);
|
||||
await builder.build();
|
||||
}
|
||||
|
@ -42,6 +37,8 @@ class AstroBuilder {
|
|||
private viteConfig?: ViteConfigWithSSR;
|
||||
|
||||
constructor(config: AstroConfig, options: BuildOptions) {
|
||||
applyPolyfill();
|
||||
|
||||
if (!config.buildOptions.site && config.buildOptions.sitemap !== false) {
|
||||
warn(options.logging, 'config', `Set "buildOptions.site" to generate correct canonical URLs and sitemap`);
|
||||
}
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import type { OutputChunk, OutputAsset, RollupOutput } from 'rollup';
|
||||
import type { Plugin as VitePlugin, UserConfig, Manifest as ViteManifest } from 'vite';
|
||||
import type { AstroConfig, ComponentInstance, ManifestData, Renderer, RouteType } from '../../@types/astro';
|
||||
import type { AstroConfig, ComponentInstance, EndpointHandler, ManifestData, Renderer, RouteType } 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 { RenderOptions } from '../../core/render/core';
|
||||
import type { SerializedSSRManifest, SerializedRouteInfo } from '../app/types';
|
||||
|
||||
import fs from 'fs';
|
||||
|
@ -20,9 +21,11 @@ import { createBuildInternals } from '../../core/build/internal.js';
|
|||
import { rollupPluginAstroBuildCSS } from '../../vite-plugin-build-css/index.js';
|
||||
import { vitePluginHoistedScripts } from './vite-plugin-hoisted-scripts.js';
|
||||
import { RouteCache } from '../render/route-cache.js';
|
||||
import { call as callEndpoint } from '../endpoint/index.js';
|
||||
import { serializeRouteData } from '../routing/index.js';
|
||||
import { render } from '../render/core.js';
|
||||
import { createLinkStylesheetElementSet, createModuleScriptElementWithSrcSet } from '../render/ssr-element.js';
|
||||
import { createRequest } from '../render/request.js';
|
||||
|
||||
export interface StaticBuildOptions {
|
||||
allPages: AllPagesData;
|
||||
|
@ -134,6 +137,8 @@ export async function staticBuild(opts: StaticBuildOptions) {
|
|||
|
||||
const topLevelImports = new Set([
|
||||
// Any component that gets hydrated
|
||||
// 'components/Counter.jsx'
|
||||
// { 'components/Counter.jsx': 'counter.hash.js' }
|
||||
...metadata.hydratedComponentPaths(),
|
||||
// Client-only components
|
||||
...metadata.clientOnlyComponentPaths(),
|
||||
|
@ -373,7 +378,7 @@ async function generatePath(pathname: string, opts: StaticBuildOptions, gopts: G
|
|||
const scripts = createModuleScriptElementWithSrcSet(hoistedId ? [hoistedId] : [], site);
|
||||
|
||||
try {
|
||||
const html = await render({
|
||||
const options: RenderOptions = {
|
||||
legacyBuild: false,
|
||||
links,
|
||||
logging,
|
||||
|
@ -392,15 +397,37 @@ async function generatePath(pathname: string, opts: StaticBuildOptions, gopts: G
|
|||
const fullyRelativePath = relPath[0] === '.' ? relPath : './' + relPath;
|
||||
return fullyRelativePath;
|
||||
},
|
||||
method: 'GET',
|
||||
headers: new Headers(),
|
||||
route: pageData.route,
|
||||
routeCache,
|
||||
site: astroConfig.buildOptions.site,
|
||||
});
|
||||
ssr: opts.astroConfig.buildOptions.experimentalSsr
|
||||
}
|
||||
|
||||
let body: string;
|
||||
if(pageData.route.type === 'endpoint') {
|
||||
|
||||
const result = await callEndpoint(mod as unknown as EndpointHandler, options);
|
||||
|
||||
if(result.type === 'response') {
|
||||
throw new Error(`Returning a Response from an endpoint is not supported in SSG mode.`)
|
||||
}
|
||||
body = result.body;
|
||||
} else {
|
||||
const result = await render(options);
|
||||
|
||||
// If there's a redirect or something, just do nothing.
|
||||
if(result.type !== 'html') {
|
||||
return;
|
||||
}
|
||||
body = result.html;
|
||||
}
|
||||
|
||||
const outFolder = getOutFolder(astroConfig, pathname, pageData.route.type);
|
||||
const outFile = getOutFile(astroConfig, outFolder, pathname, pageData.route.type);
|
||||
await fs.promises.mkdir(outFolder, { recursive: true });
|
||||
await fs.promises.writeFile(outFile, html, 'utf-8');
|
||||
await fs.promises.writeFile(outFile, body, 'utf-8');
|
||||
} catch (err) {
|
||||
error(opts.logging, 'build', `Error rendering:`, err);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { polyfill } from '@astrojs/webapi';
|
||||
import type { AddressInfo } from 'net';
|
||||
import { performance } from 'perf_hooks';
|
||||
import type { AstroConfig } from '../../@types/astro';
|
||||
import type { AddressInfo } from 'net';
|
||||
|
||||
import { performance } from 'perf_hooks';
|
||||
import { apply as applyPolyfill } from '../polyfill.js';
|
||||
import { createVite } from '../create-vite.js';
|
||||
import { defaultLogOptions, info, warn, LogOptions } from '../logger.js';
|
||||
import * as vite from 'vite';
|
||||
|
@ -20,10 +21,7 @@ export interface DevServer {
|
|||
/** `astro dev` */
|
||||
export default async function dev(config: AstroConfig, options: DevOptions = { logging: defaultLogOptions }): Promise<DevServer> {
|
||||
const devStart = performance.now();
|
||||
// polyfill WebAPIs for Node.js runtime
|
||||
polyfill(globalThis, {
|
||||
exclude: 'window document',
|
||||
});
|
||||
applyPolyfill();
|
||||
|
||||
// TODO: remove call once --hostname is baselined
|
||||
const host = getResolvedHostForVite(config);
|
||||
|
|
23
packages/astro/src/core/endpoint/dev/index.ts
Normal file
23
packages/astro/src/core/endpoint/dev/index.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import type { EndpointHandler } from '../../../@types/astro';
|
||||
import type { SSROptions } from '../../render/dev';
|
||||
|
||||
import { preload } from '../../render/dev/index.js';
|
||||
import { errorHandler } from '../../render/dev/error.js';
|
||||
import { call as callEndpoint } from '../index.js';
|
||||
import { getParamsAndProps, GetParamsAndPropsError } from '../../render/core.js';
|
||||
import { createRequest } from '../../render/request.js';
|
||||
|
||||
|
||||
export async function call(ssrOpts: SSROptions) {
|
||||
try {
|
||||
const [, mod] = await preload(ssrOpts);
|
||||
return await callEndpoint(mod as unknown as EndpointHandler, {
|
||||
...ssrOpts,
|
||||
ssr: ssrOpts.astroConfig.buildOptions.experimentalSsr
|
||||
});
|
||||
|
||||
} catch (e: unknown) {
|
||||
await errorHandler(e, { viteServer: ssrOpts.viteServer, filePath: ssrOpts.filePath });
|
||||
throw e;
|
||||
}
|
||||
}
|
51
packages/astro/src/core/endpoint/index.ts
Normal file
51
packages/astro/src/core/endpoint/index.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
import type { EndpointHandler } from '../../@types/astro';
|
||||
import type { RenderOptions } from '../render/core';
|
||||
import { renderEndpoint } from '../../runtime/server/index.js';
|
||||
import { getParamsAndProps, GetParamsAndPropsError } from '../render/core.js';
|
||||
import { createRequest } from '../render/request.js';
|
||||
|
||||
export type EndpointOptions = Pick<RenderOptions,
|
||||
'logging' |
|
||||
'headers' |
|
||||
'method' |
|
||||
'origin' |
|
||||
'route' |
|
||||
'routeCache' |
|
||||
'pathname' |
|
||||
'route' |
|
||||
'site' |
|
||||
'ssr'
|
||||
>;
|
||||
|
||||
type EndpointCallResult = {
|
||||
type: 'simple',
|
||||
body: string
|
||||
} | {
|
||||
type: 'response',
|
||||
response: Response
|
||||
};
|
||||
|
||||
export async function call(mod: EndpointHandler, opts: EndpointOptions): Promise<EndpointCallResult> {
|
||||
const paramsAndPropsResp = await getParamsAndProps({ ...opts, mod: (mod as any) });
|
||||
|
||||
if (paramsAndPropsResp === GetParamsAndPropsError.NoMatchingStaticPath) {
|
||||
throw new Error(`[getStaticPath] route pattern matched, but no matching static path found. (${opts.pathname})`);
|
||||
}
|
||||
const [params] = paramsAndPropsResp;
|
||||
const request = createRequest(opts.method, opts.pathname, opts.headers, opts.origin,
|
||||
opts.site, opts.ssr);
|
||||
|
||||
const response = await renderEndpoint(mod, request, params);
|
||||
|
||||
if(response instanceof Response) {
|
||||
return {
|
||||
type: 'response',
|
||||
response
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'simple',
|
||||
body: response.body
|
||||
};
|
||||
}
|
8
packages/astro/src/core/polyfill.ts
Normal file
8
packages/astro/src/core/polyfill.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { polyfill } from '@astrojs/webapi';
|
||||
|
||||
export function apply() {
|
||||
// polyfill WebAPIs for Node.js runtime
|
||||
polyfill(globalThis, {
|
||||
exclude: 'window document',
|
||||
});
|
||||
}
|
|
@ -1,15 +1,15 @@
|
|||
import type { ComponentInstance, EndpointHandler, MarkdownRenderOptions, Params, Props, Renderer, RouteData, SSRElement } from '../../@types/astro';
|
||||
import type { LogOptions } from '../logger.js';
|
||||
import type { AstroRequest } from './request';
|
||||
|
||||
import { renderEndpoint, renderHead, renderToString } from '../../runtime/server/index.js';
|
||||
import { renderHead, 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;
|
||||
route?: RouteData | undefined;
|
||||
routeCache: RouteCache;
|
||||
pathname: string;
|
||||
logging: LogOptions;
|
||||
|
@ -55,7 +55,7 @@ export async function getParamsAndProps(opts: GetParamsAndPropsOptions): Promise
|
|||
return [params, pageProps];
|
||||
}
|
||||
|
||||
interface RenderOptions {
|
||||
export interface RenderOptions {
|
||||
legacyBuild: boolean;
|
||||
logging: LogOptions;
|
||||
links: Set<SSRElement>;
|
||||
|
@ -69,10 +69,13 @@ interface RenderOptions {
|
|||
route?: RouteData;
|
||||
routeCache: RouteCache;
|
||||
site?: string;
|
||||
ssr: boolean;
|
||||
method: string;
|
||||
headers: Headers;
|
||||
}
|
||||
|
||||
export async function render(opts: RenderOptions): Promise<string> {
|
||||
const { legacyBuild, links, logging, origin, markdownRender, mod, pathname, scripts, renderers, resolve, route, routeCache, site } = opts;
|
||||
export async function render(opts: RenderOptions): Promise<{ type: 'html', html: string } | { type: 'response', response: Response }> {
|
||||
const { headers, legacyBuild, links, logging, origin, markdownRender, method, mod, pathname, scripts, renderers, resolve, route, routeCache, site, ssr } = opts;
|
||||
|
||||
const paramsAndPropsRes = await getParamsAndProps({
|
||||
logging,
|
||||
|
@ -87,11 +90,6 @@ export async function render(opts: RenderOptions): Promise<string> {
|
|||
}
|
||||
const [params, pageProps] = paramsAndPropsRes;
|
||||
|
||||
// For endpoints, render the content immediately without injecting scripts or styles
|
||||
if (route?.type === 'endpoint') {
|
||||
return renderEndpoint(mod as any as EndpointHandler, params);
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
|
@ -109,10 +107,18 @@ export async function render(opts: RenderOptions): Promise<string> {
|
|||
renderers,
|
||||
site,
|
||||
scripts,
|
||||
ssr,
|
||||
method,
|
||||
headers
|
||||
});
|
||||
|
||||
let html = await renderToString(result, Component, pageProps, null);
|
||||
let page = await renderPage(result, Component, pageProps, null);
|
||||
|
||||
if(page.type === 'response') {
|
||||
return page;
|
||||
}
|
||||
|
||||
let html = page.html;
|
||||
// handle final head injection if it hasn't happened already
|
||||
if (html.indexOf('<!--astro:head:injected-->') == -1) {
|
||||
html = (await renderHead(result)) + html;
|
||||
|
@ -124,6 +130,9 @@ export async function render(opts: RenderOptions): Promise<string> {
|
|||
if (!legacyBuild && !/<!doctype html/i.test(html)) {
|
||||
html = '<!DOCTYPE html>\n' + html;
|
||||
}
|
||||
|
||||
return html;
|
||||
|
||||
return {
|
||||
type: 'html',
|
||||
html
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import type * as vite from 'vite';
|
||||
import type { AstroConfig, ComponentInstance, Renderer, RouteData, RuntimeMode, SSRElement } from '../../../@types/astro';
|
||||
import type { AstroRequest } from '../request';
|
||||
|
||||
import { LogOptions } from '../../logger.js';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { getStylesForURL } from './css.js';
|
||||
|
@ -12,7 +14,7 @@ import { prependForwardSlash } from '../../path.js';
|
|||
import { render as coreRender } from '../core.js';
|
||||
import { createModuleScriptElementWithSrcSet } from '../ssr-element.js';
|
||||
|
||||
interface SSROptions {
|
||||
export interface SSROptions {
|
||||
/** an instance of the AstroConfig */
|
||||
astroConfig: AstroConfig;
|
||||
/** location of file on disk */
|
||||
|
@ -31,10 +33,18 @@ interface SSROptions {
|
|||
routeCache: RouteCache;
|
||||
/** Vite instance */
|
||||
viteServer: vite.ViteDevServer;
|
||||
/** Method */
|
||||
method: string;
|
||||
/** Headers */
|
||||
headers: Headers;
|
||||
}
|
||||
|
||||
export type ComponentPreload = [Renderer[], ComponentInstance];
|
||||
|
||||
export type RenderResponse =
|
||||
{ type: 'html', html: string } |
|
||||
{ type: 'response', response: Response };
|
||||
|
||||
const svelteStylesRE = /svelte\?svelte&type=style/;
|
||||
|
||||
export async function preload({ astroConfig, filePath, viteServer }: Pick<SSROptions, 'astroConfig' | 'filePath' | 'viteServer'>): Promise<ComponentPreload> {
|
||||
|
@ -47,8 +57,8 @@ export async function preload({ astroConfig, filePath, viteServer }: Pick<SSROpt
|
|||
}
|
||||
|
||||
/** 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;
|
||||
export async function render(renderers: Renderer[], mod: ComponentInstance, ssrOpts: SSROptions): Promise<RenderResponse> {
|
||||
const { astroConfig, filePath, logging, mode, origin, pathname, method, headers, route, routeCache, viteServer } = ssrOpts;
|
||||
const legacy = astroConfig.buildOptions.legacyBuild;
|
||||
|
||||
// Add hoisted script tags
|
||||
|
@ -113,9 +123,12 @@ export async function render(renderers: Renderer[], mod: ComponentInstance, ssrO
|
|||
route,
|
||||
routeCache,
|
||||
site: astroConfig.buildOptions.site,
|
||||
ssr: astroConfig.buildOptions.experimentalSsr,
|
||||
method,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (route?.type === 'endpoint') {
|
||||
if (route?.type === 'endpoint' || content.type === 'response') {
|
||||
return content;
|
||||
}
|
||||
|
||||
|
@ -158,23 +171,26 @@ export async function render(renderers: Renderer[], mod: ComponentInstance, ssrO
|
|||
}
|
||||
|
||||
// add injected tags
|
||||
content = injectTags(content, tags);
|
||||
let html = injectTags(content.html, tags);
|
||||
|
||||
// run transformIndexHtml() in dev to run Vite dev transformations
|
||||
if (mode === 'development' && astroConfig.buildOptions.legacyBuild) {
|
||||
const relativeURL = filePath.href.replace(astroConfig.projectRoot.href, '/');
|
||||
content = await viteServer.transformIndexHtml(relativeURL, content, pathname);
|
||||
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(content)) {
|
||||
content = '<!DOCTYPE html>\n' + content;
|
||||
if (!/<!doctype html/i.test(html)) {
|
||||
html = '<!DOCTYPE html>\n' + content;
|
||||
}
|
||||
|
||||
return content;
|
||||
return {
|
||||
type: 'html',
|
||||
html
|
||||
};
|
||||
}
|
||||
|
||||
export async function ssr(preloadedComponent: ComponentPreload, ssrOpts: SSROptions): Promise<string> {
|
||||
export async function ssr(preloadedComponent: ComponentPreload, ssrOpts: SSROptions): Promise<RenderResponse> {
|
||||
try {
|
||||
const [renderers, mod] = preloadedComponent;
|
||||
return await render(renderers, mod, ssrOpts); // note(drew): without "await", errors won’t get caught by errorHandler()
|
||||
|
|
51
packages/astro/src/core/render/request.ts
Normal file
51
packages/astro/src/core/render/request.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
import type { Params } from '../../@types/astro';
|
||||
import { canonicalURL as utilCanonicalURL } from '../util.js';
|
||||
|
||||
type Site = string | undefined;
|
||||
|
||||
export interface AstroRequest {
|
||||
/** get the current page URL */
|
||||
url: URL;
|
||||
|
||||
/** get the current canonical URL */
|
||||
canonicalURL: URL;
|
||||
|
||||
/** get page params (dynamic pages only) */
|
||||
params: Params;
|
||||
|
||||
headers: Headers;
|
||||
|
||||
method: string;
|
||||
}
|
||||
|
||||
export type AstroRequestSSR = AstroRequest
|
||||
|
||||
export function createRequest(method: string, pathname: string, headers: Headers,
|
||||
origin: string, site: Site, ssr: boolean): AstroRequest {
|
||||
const url = new URL('.' + pathname, new URL(origin));
|
||||
|
||||
const canonicalURL = utilCanonicalURL('.' + pathname, site ?? url.origin);
|
||||
|
||||
const request: AstroRequest = {
|
||||
url,
|
||||
canonicalURL,
|
||||
params: {},
|
||||
headers,
|
||||
method
|
||||
};
|
||||
|
||||
if(!ssr) {
|
||||
// Headers are only readable if using SSR-mode. If not, make it an empty headers
|
||||
// object, so you can't do something bad.
|
||||
request.headers = new Headers();
|
||||
|
||||
// Disallow using query params.
|
||||
request.url = new URL(request.url);
|
||||
|
||||
for(const [key] of request.url.searchParams) {
|
||||
request.url.searchParams.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
|
@ -1,13 +1,22 @@
|
|||
import type { AstroGlobal, AstroGlobalPartial, MarkdownParser, MarkdownRenderOptions, Params, Renderer, SSRElement, SSRResult } from '../../@types/astro';
|
||||
import type { AstroRequest } from './request';
|
||||
|
||||
import { bold } from 'kleur/colors';
|
||||
import { canonicalURL as getCanonicalURL } from '../util.js';
|
||||
import { createRequest } from './request.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';
|
||||
|
||||
function onlyAvailableInSSR(name: string) {
|
||||
return function() {
|
||||
// TODO add more guidance when we have docs and adapters.
|
||||
throw new Error(`Oops, you are trying to use ${name}, which is only available with SSR.`)
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateResultArgs {
|
||||
ssr: boolean;
|
||||
legacyBuild: boolean;
|
||||
logging: LogOptions;
|
||||
origin: string;
|
||||
|
@ -19,6 +28,8 @@ export interface CreateResultArgs {
|
|||
site: string | undefined;
|
||||
links?: Set<SSRElement>;
|
||||
scripts?: Set<SSRElement>;
|
||||
headers: Headers;
|
||||
method: string;
|
||||
}
|
||||
|
||||
class Slots {
|
||||
|
@ -63,7 +74,10 @@ class Slots {
|
|||
}
|
||||
|
||||
export function createResult(args: CreateResultArgs): SSRResult {
|
||||
const { legacyBuild, origin, markdownRender, params, pathname, renderers, resolve, site: buildOptionsSite } = args;
|
||||
const { legacyBuild, markdownRender, method, origin, headers, params, pathname, renderers, resolve, site } = args;
|
||||
|
||||
const request = createRequest(method, pathname, headers, origin, site, args.ssr);
|
||||
request.params = params;
|
||||
|
||||
// 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
|
||||
|
@ -74,19 +88,20 @@ export function createResult(args: CreateResultArgs): SSRResult {
|
|||
links: args.links ?? new Set<SSRElement>(),
|
||||
/** This function returns the `Astro` faux-global */
|
||||
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, buildOptionsSite || origin);
|
||||
const astroSlots = new Slots(result, slots);
|
||||
|
||||
return {
|
||||
__proto__: astroGlobal,
|
||||
props,
|
||||
request: {
|
||||
canonicalURL,
|
||||
params,
|
||||
url,
|
||||
},
|
||||
request,
|
||||
redirect: args.ssr ? (path: string) => {
|
||||
return new Response(null, {
|
||||
status: 301,
|
||||
headers: {
|
||||
Location: path
|
||||
}
|
||||
});
|
||||
} : onlyAvailableInSSR('Astro.redirect'),
|
||||
resolve(path: string) {
|
||||
if (!legacyBuild) {
|
||||
let extra = `This can be replaced with a dynamic import like so: await import("${path}")`;
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import type { AstroComponentMetadata, EndpointHandler, Renderer } from '../../@types/astro';
|
||||
import type { AstroComponentMetadata, EndpointHandler, Renderer, Params } from '../../@types/astro';
|
||||
import type { AstroGlobalPartial, SSRResult, SSRElement } from '../../@types/astro';
|
||||
import type { AstroRequest } from '../../core/render/request';
|
||||
|
||||
import shorthash from 'shorthash';
|
||||
import { extractDirectives, generateHydrateScript } from './hydration.js';
|
||||
|
@ -80,13 +81,17 @@ export class AstroComponent {
|
|||
}
|
||||
}
|
||||
|
||||
function isAstroComponent(obj: any): obj is AstroComponent {
|
||||
return typeof obj === 'object' && Object.prototype.toString.call(obj) === '[object AstroComponent]';
|
||||
}
|
||||
|
||||
export async function render(htmlParts: TemplateStringsArray, ...expressions: any[]) {
|
||||
return new AstroComponent(htmlParts, expressions);
|
||||
}
|
||||
|
||||
// The callback passed to to $$createComponent
|
||||
export interface AstroComponentFactory {
|
||||
(result: any, props: any, slots: any): ReturnType<typeof render>;
|
||||
(result: any, props: any, slots: any): ReturnType<typeof render> | Response;
|
||||
isAstroComponentFactory?: boolean;
|
||||
}
|
||||
|
||||
|
@ -407,24 +412,19 @@ export function defineScriptVars(vars: Record<any, any>) {
|
|||
}
|
||||
|
||||
// Renders an endpoint request to completion, returning the body.
|
||||
export async function renderEndpoint(mod: EndpointHandler, params: any) {
|
||||
const method = 'get';
|
||||
const handler = mod[method];
|
||||
export async function renderEndpoint(mod: EndpointHandler, request: AstroRequest, params: Params) {
|
||||
const chosenMethod = request.method?.toLowerCase() ?? 'get';
|
||||
const handler = mod[chosenMethod];
|
||||
|
||||
if (!handler || typeof handler !== 'function') {
|
||||
throw new Error(`Endpoint handler not found! Expected an exported function for "${method}"`);
|
||||
throw new Error(`Endpoint handler not found! Expected an exported function for "${chosenMethod}"`);
|
||||
}
|
||||
|
||||
const { body } = await mod.get(params);
|
||||
|
||||
return body;
|
||||
return await handler.call(mod, params, request);
|
||||
}
|
||||
|
||||
// Calls a component and renders it into a string of HTML
|
||||
export async function renderToString(result: SSRResult, componentFactory: AstroComponentFactory, props: any, children: any): Promise<string> {
|
||||
const Component = await componentFactory(result, props, children);
|
||||
let template = await renderAstroComponent(Component);
|
||||
|
||||
async function replaceHeadInjection(result: SSRResult, html: string): Promise<string> {
|
||||
let template = html;
|
||||
// <!--astro:head--> injected by compiler
|
||||
// Must be handled at the end of the rendering process
|
||||
if (template.indexOf('<!--astro:head-->') > -1) {
|
||||
|
@ -433,6 +433,41 @@ export async function renderToString(result: SSRResult, componentFactory: AstroC
|
|||
return template;
|
||||
}
|
||||
|
||||
// Calls a component and renders it into a string of HTML
|
||||
export async function renderToString(result: SSRResult, componentFactory: AstroComponentFactory,
|
||||
props: any, children: any): Promise<string> {
|
||||
const Component = await componentFactory(result, props, children);
|
||||
if(!isAstroComponent(Component)) {
|
||||
throw new Error('Cannot return a Response from a nested component.');
|
||||
}
|
||||
|
||||
let template = await renderAstroComponent(Component);
|
||||
return replaceHeadInjection(result, template);
|
||||
}
|
||||
|
||||
export async function renderPage(
|
||||
result: SSRResult,
|
||||
componentFactory: AstroComponentFactory,
|
||||
props: any,
|
||||
children: any
|
||||
): Promise<{ type: 'html', html: string } | { type: 'response', response: Response }> {
|
||||
const response = await componentFactory(result, props, children);
|
||||
|
||||
if(isAstroComponent(response)) {
|
||||
let template = await renderAstroComponent(response);
|
||||
const html = await replaceHeadInjection(result, template);
|
||||
return {
|
||||
type: 'html',
|
||||
html
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: 'response',
|
||||
response
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out duplicate elements in our set
|
||||
const uniqueElements = (item: any, index: number, all: any[]) => {
|
||||
const props = JSON.stringify(item.props);
|
||||
|
|
|
@ -1,17 +1,20 @@
|
|||
import type * as vite from 'vite';
|
||||
import type http from 'http';
|
||||
import type { AstroConfig, ManifestData } from '../@types/astro';
|
||||
import type { RenderResponse, SSROptions } from '../core/render/dev/index';
|
||||
import { info, warn, error, LogOptions } from '../core/logger.js';
|
||||
import { getParamsAndProps, GetParamsAndPropsError } from '../core/render/core.js';
|
||||
import { createRouteManifest, matchRoute } from '../core/routing/index.js';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import { createSafeError } from '../core/util.js';
|
||||
import { ssr, preload } from '../core/render/dev/index.js';
|
||||
import { call as callEndpoint } from '../core/endpoint/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/render/route-cache.js';
|
||||
import { AstroRequest } from '../core/render/request.js';
|
||||
|
||||
interface AstroPluginOptions {
|
||||
config: AstroConfig;
|
||||
|
@ -37,6 +40,33 @@ function writeHtmlResponse(res: http.ServerResponse, statusCode: number, html: s
|
|||
res.end();
|
||||
}
|
||||
|
||||
async function writeWebResponse(res: http.ServerResponse, webResponse: Response) {
|
||||
const { status, headers, body } = webResponse;
|
||||
res.writeHead(status, Object.fromEntries(headers.entries()));
|
||||
if(body) {
|
||||
const reader = body.getReader();
|
||||
while(true) {
|
||||
const { done, value } = await reader.read();
|
||||
if(done) break;
|
||||
if(value) {
|
||||
res.write(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
res.end();
|
||||
}
|
||||
|
||||
async function writeSSRResult(result: RenderResponse, res: http.ServerResponse, statusCode: 200 | 404) {
|
||||
if(result.type === 'response') {
|
||||
const { response } = result;
|
||||
await writeWebResponse(res, response);
|
||||
return;
|
||||
}
|
||||
|
||||
const { html } = result;
|
||||
writeHtmlResponse(res, statusCode, html);
|
||||
}
|
||||
|
||||
async function handle404Response(origin: string, config: AstroConfig, req: http.IncomingMessage, res: http.ServerResponse) {
|
||||
const site = config.buildOptions.site ? new URL(config.buildOptions.site) : undefined;
|
||||
const devRoot = site ? site.pathname : '/';
|
||||
|
@ -87,7 +117,8 @@ async function handleRequest(
|
|||
const site = config.buildOptions.site ? new URL(config.buildOptions.site) : undefined;
|
||||
const devRoot = site ? site.pathname : '/';
|
||||
const origin = `${viteServer.config.server.https ? 'https' : 'http'}://${req.headers.host}`;
|
||||
const pathname = decodeURI(new URL(origin + req.url).pathname);
|
||||
const url = new URL(origin + req.url);
|
||||
const pathname = decodeURI(url.pathname);
|
||||
const rootRelativeUrl = pathname.substring(devRoot.length - 1);
|
||||
|
||||
try {
|
||||
|
@ -129,24 +160,26 @@ async function handleRequest(
|
|||
if (routeCustom404) {
|
||||
const filePathCustom404 = new URL(`./${routeCustom404.component}`, config.projectRoot);
|
||||
const preloadedCompCustom404 = await preload({ astroConfig: config, filePath: filePathCustom404, viteServer });
|
||||
const html = await ssr(preloadedCompCustom404, {
|
||||
const result = await ssr(preloadedCompCustom404, {
|
||||
astroConfig: config,
|
||||
filePath: filePathCustom404,
|
||||
logging,
|
||||
mode: 'development',
|
||||
method: 'GET',
|
||||
headers: new Headers(Object.entries(req.headers as Record<string, any>)),
|
||||
origin,
|
||||
pathname: rootRelativeUrl,
|
||||
route: routeCustom404,
|
||||
routeCache,
|
||||
viteServer,
|
||||
});
|
||||
return writeHtmlResponse(res, statusCode, html);
|
||||
return await writeSSRResult(result, res, statusCode);
|
||||
} else {
|
||||
return handle404Response(origin, config, req, res);
|
||||
}
|
||||
}
|
||||
|
||||
const html = await ssr(preloadedComponent, {
|
||||
const options: SSROptions = {
|
||||
astroConfig: config,
|
||||
filePath,
|
||||
logging,
|
||||
|
@ -156,9 +189,25 @@ async function handleRequest(
|
|||
route,
|
||||
routeCache,
|
||||
viteServer,
|
||||
});
|
||||
writeHtmlResponse(res, statusCode, html);
|
||||
method: req.method || 'GET',
|
||||
headers: new Headers(Object.entries(req.headers as Record<string, any>)),
|
||||
};
|
||||
|
||||
// Route successfully matched! Render it.
|
||||
if(route.type === 'endpoint') {
|
||||
const result = await callEndpoint(options);
|
||||
if(result.type === 'response') {
|
||||
await writeWebResponse(res, result.response);
|
||||
} else {
|
||||
res.writeHead(200);
|
||||
res.end(result.body);
|
||||
}
|
||||
} else {
|
||||
const result = await ssr(preloadedComponent, options);
|
||||
return await writeSSRResult(result, res, statusCode);
|
||||
}
|
||||
} catch (_err: any) {
|
||||
debugger;
|
||||
info(logging, 'serve', msg.req({ url: pathname, statusCode: 500 }));
|
||||
const err = createSafeError(_err);
|
||||
error(logging, 'error', msg.err(err));
|
||||
|
|
|
@ -83,10 +83,12 @@ export function rollupPluginAstroBuildHTML(options: PluginOptions): VitePlugin {
|
|||
for (const pathname of pageData.paths) {
|
||||
pageNames.push(pathname.replace(/\/?$/, '/').replace(/^\//, ''));
|
||||
const id = ASTRO_PAGE_PREFIX + pathname;
|
||||
const html = await ssrRender(renderers, mod, {
|
||||
const response = await ssrRender(renderers, mod, {
|
||||
astroConfig,
|
||||
filePath: new URL(`./${component}`, astroConfig.projectRoot),
|
||||
logging,
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
mode: 'production',
|
||||
origin,
|
||||
pathname,
|
||||
|
@ -94,6 +96,12 @@ export function rollupPluginAstroBuildHTML(options: PluginOptions): VitePlugin {
|
|||
routeCache,
|
||||
viteServer,
|
||||
});
|
||||
|
||||
if(response.type !== 'html') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const html = response.html;
|
||||
renderedPageMap.set(id, html);
|
||||
|
||||
const document = parse5.parse(html, {
|
||||
|
|
|
@ -80,7 +80,7 @@ export async function loadFixture(inlineConfig) {
|
|||
return devResult;
|
||||
},
|
||||
config,
|
||||
fetch: (url, init) => fetch(`http://${config.devOptions.hostname}:${config.devOptions.port}${url.replace(/^\/?/, '/')}`, init),
|
||||
fetch: (url, init) => fetch(`http://${'127.0.0.1'}:${config.devOptions.port}${url.replace(/^\/?/, '/')}`, init),
|
||||
preview: async (opts = {}) => {
|
||||
const previewServer = await preview(config, { logging: 'error', ...opts });
|
||||
inlineConfig.devOptions.port = previewServer.port; // update port for fetch
|
||||
|
|
|
@ -203,11 +203,15 @@ importers:
|
|||
specifiers:
|
||||
'@astrojs/renderer-svelte': ^0.5.2
|
||||
astro: ^0.24.3
|
||||
concurrently: ^7.0.0
|
||||
lightcookie: ^1.0.25
|
||||
unocss: ^0.15.6
|
||||
vite-imagetools: ^4.0.3
|
||||
devDependencies:
|
||||
'@astrojs/renderer-svelte': link:../../packages/renderers/renderer-svelte
|
||||
astro: link:../../packages/astro
|
||||
concurrently: 7.0.0
|
||||
lightcookie: 1.0.25
|
||||
unocss: 0.15.6
|
||||
vite-imagetools: 4.0.3
|
||||
|
||||
|
@ -4636,6 +4640,21 @@ packages:
|
|||
/concat-map/0.0.1:
|
||||
resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=}
|
||||
|
||||
/concurrently/7.0.0:
|
||||
resolution: {integrity: sha512-WKM7PUsI8wyXpF80H+zjHP32fsgsHNQfPLw/e70Z5dYkV7hF+rf8q3D+ScWJIEr57CpkO3OWBko6hwhQLPR8Pw==}
|
||||
engines: {node: ^12.20.0 || ^14.13.0 || >=16.0.0}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
chalk: 4.1.2
|
||||
date-fns: 2.28.0
|
||||
lodash: 4.17.21
|
||||
rxjs: 6.6.7
|
||||
spawn-command: 0.0.2-1
|
||||
supports-color: 8.1.1
|
||||
tree-kill: 1.2.2
|
||||
yargs: 16.2.0
|
||||
dev: true
|
||||
|
||||
/consola/2.15.3:
|
||||
resolution: {integrity: sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==}
|
||||
dev: true
|
||||
|
@ -4760,6 +4779,11 @@ packages:
|
|||
resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==}
|
||||
dev: true
|
||||
|
||||
/date-fns/2.28.0:
|
||||
resolution: {integrity: sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw==}
|
||||
engines: {node: '>=0.11'}
|
||||
dev: true
|
||||
|
||||
/debug/4.3.3:
|
||||
resolution: {integrity: sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==}
|
||||
engines: {node: '>=6.0'}
|
||||
|
@ -6837,6 +6861,10 @@ packages:
|
|||
type-check: 0.4.0
|
||||
dev: true
|
||||
|
||||
/lightcookie/1.0.25:
|
||||
resolution: {integrity: sha512-SrY/+eBPaKAMnsn7mCsoOMZzoQyCyHHHZlFCu2fjo28XxSyCLjlooKiTxyrXTg8NPaHp1YzWi0lcGG1gDi6KHw==}
|
||||
dev: true
|
||||
|
||||
/lilconfig/2.0.4:
|
||||
resolution: {integrity: sha512-bfTIN7lEsiooCocSISTWXkiWJkRqtL9wYtYy+8EK3Y41qh3mpwPU0ycTOgjdY9ErwXCc8QyrQp82bdL0Xkm9yA==}
|
||||
engines: {node: '>=10'}
|
||||
|
@ -8572,6 +8600,13 @@ packages:
|
|||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
|
||||
/rxjs/6.6.7:
|
||||
resolution: {integrity: sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==}
|
||||
engines: {npm: '>=2.0.0'}
|
||||
dependencies:
|
||||
tslib: 1.14.1
|
||||
dev: true
|
||||
|
||||
/sade/1.8.1:
|
||||
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
|
||||
engines: {node: '>=6'}
|
||||
|
@ -8867,6 +8902,10 @@ packages:
|
|||
/space-separated-tokens/2.0.1:
|
||||
resolution: {integrity: sha512-ekwEbFp5aqSPKaqeY1PGrlGQxPNaq+Cnx4+bE2D8sciBQrHpbwoBbawqTN2+6jPs9IdWxxiUcN0K2pkczD3zmw==}
|
||||
|
||||
/spawn-command/0.0.2-1:
|
||||
resolution: {integrity: sha1-YvXpRmmBwbeW3Fkpk34RycaSG9A=}
|
||||
dev: true
|
||||
|
||||
/spawndamnit/2.0.0:
|
||||
resolution: {integrity: sha512-j4JKEcncSjFlqIwU5L/rp2N5SIPsdxaRsIv678+TZxZ0SRDJTm8JrxJMjE/XuiEZNEir3S8l0Fa3Ke339WI4qA==}
|
||||
dependencies:
|
||||
|
@ -9318,6 +9357,11 @@ packages:
|
|||
punycode: 2.1.1
|
||||
dev: true
|
||||
|
||||
/tree-kill/1.2.2:
|
||||
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/trim-newlines/3.0.1:
|
||||
resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==}
|
||||
engines: {node: '>=8'}
|
||||
|
@ -10077,7 +10121,7 @@ packages:
|
|||
/wide-align/1.1.5:
|
||||
resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==}
|
||||
dependencies:
|
||||
string-width: 1.0.2
|
||||
string-width: 4.2.3
|
||||
dev: true
|
||||
|
||||
/word-wrap/1.2.3:
|
||||
|
|
Loading…
Reference in a new issue