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:
Matthew Phillips 2022-03-16 12:16:21 -04:00 committed by GitHub
parent 8f13b3d406
commit 4c25a1c2ea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 698 additions and 109 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Implement APIs for headers for SSR flag

View file

@ -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,
}
},
},
},

View file

@ -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"
}

View file

@ -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) {

View file

@ -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'),
});

View file

@ -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
})
});
}

View file

@ -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 {

View file

@ -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>

View file

@ -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>

View 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;
}

View 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>

View 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>

View 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'
}
});
}

View file

@ -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>

View file

@ -0,0 +1,8 @@
{
"compilerOptions": {
"lib": ["ES2015", "DOM"],
"module": "ES2022",
"moduleResolution": "node",
"types": ["astro/env"]
}
}

View file

@ -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;
}
/**

View file

@ -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[]> {

View file

@ -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));
}
}

View file

@ -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`);
}

View file

@ -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);
}

View file

@ -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);

View 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;
}
}

View 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
};
}

View file

@ -0,0 +1,8 @@
import { polyfill } from '@astrojs/webapi';
export function apply() {
// polyfill WebAPIs for Node.js runtime
polyfill(globalThis, {
exclude: 'window document',
});
}

View file

@ -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
};
}

View file

@ -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 wont get caught by errorHandler()

View 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;
}

View file

@ -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}")`;

View file

@ -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);

View file

@ -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));

View file

@ -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, {

View file

@ -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

View file

@ -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: