diff --git a/.changeset/slow-islands-fix.md b/.changeset/slow-islands-fix.md new file mode 100644 index 000000000..1da871343 --- /dev/null +++ b/.changeset/slow-islands-fix.md @@ -0,0 +1,29 @@ +--- +'astro': patch +--- + +Experimental SSR Support + +> ⚠️ If you are a user of Astro and see this PR and think that you can start deploying your app to a server and get SSR, slow down a second! This is only the initial flag and **very basic support**. Styles are not loading correctly at this point, for example. Like we did with the `--experimental-static-build` flag, this feature will be refined over the next few weeks/months and we'll let you know when its ready for community testing. + +## Changes + +- This adds a new `--experimental-ssr` flag to `astro build` which will result in `dist/server/` and `dist/client/` directories. +- SSR can be used through this API: + ```js + import { createServer } from 'http'; + import { loadApp } from 'astro/app/node'; + + const app = await loadApp(new URL('./dist/server/', import.meta.url)); + + createServer((req, res) => { + const route = app.match(req); + if(route) { + let html = await app.render(req, route); + } + + }).listen(8080); + ``` +- This API will be refined over time. +- This only works in Node.js at the moment. +- Many features will likely not work correctly, but rendering HTML at least should. diff --git a/comp.txt b/comp.txt new file mode 100644 index 000000000..e69de29bb diff --git a/examples/fast-build/package.json b/examples/fast-build/package.json index 924671aa8..21fe139d5 100644 --- a/examples/fast-build/package.json +++ b/examples/fast-build/package.json @@ -11,7 +11,7 @@ }, "devDependencies": { "astro": "^0.23.0-next.6", - "preact": "~10.5.15", + "preact": "~10.6.5", "unocss": "^0.15.5", "vite-imagetools": "^4.0.1" } diff --git a/examples/ssr/astro.config.mjs b/examples/ssr/astro.config.mjs new file mode 100644 index 000000000..7c986b97d --- /dev/null +++ b/examples/ssr/astro.config.mjs @@ -0,0 +1,12 @@ +// @ts-check + +export default /** @type {import('astro').AstroUserConfig} */ ({ + renderers: ['@astrojs/renderer-svelte'], + vite: { + server: { + proxy: { + '/api': 'http://localhost:8085' + } + } + } +}); diff --git a/examples/ssr/build.mjs b/examples/ssr/build.mjs new file mode 100644 index 000000000..5d2e4a3aa --- /dev/null +++ b/examples/ssr/build.mjs @@ -0,0 +1,12 @@ +import {execa} from 'execa'; + +const api = execa('npm', ['run', 'dev-api']); +api.stdout.pipe(process.stdout); +api.stderr.pipe(process.stderr); + +const build = execa('yarn', ['astro', 'build', '--experimental-ssr']); +build.stdout.pipe(process.stdout); +build.stderr.pipe(process.stderr); +await build; + +api.kill(); diff --git a/examples/ssr/package.json b/examples/ssr/package.json new file mode 100644 index 000000000..c783a5416 --- /dev/null +++ b/examples/ssr/package.json @@ -0,0 +1,21 @@ +{ + "name": "@example/ssr", + "version": "0.0.1", + "private": true, + "scripts": { + "dev-api": "node server/dev-api.mjs", + "dev": "npm run dev-api & astro dev --experimental-ssr", + "start": "astro dev", + "build": "echo 'Run yarn build-ssr instead'", + "build-ssr": "node build.mjs", + "server": "node server/server.mjs" + }, + "devDependencies": { + "astro": "^0.23.0-next.0", + "unocss": "^0.15.5", + "vite-imagetools": "^4.0.1" + }, + "dependencies": { + "@astropub/webapi": "^0.10.13" + } +} diff --git a/examples/ssr/public/images/products/cereal.jpg b/examples/ssr/public/images/products/cereal.jpg new file mode 100644 index 000000000..c1f4cce4a Binary files /dev/null and b/examples/ssr/public/images/products/cereal.jpg differ diff --git a/examples/ssr/public/images/products/muffins.jpg b/examples/ssr/public/images/products/muffins.jpg new file mode 100644 index 000000000..897733ee8 Binary files /dev/null and b/examples/ssr/public/images/products/muffins.jpg differ diff --git a/examples/ssr/public/images/products/oats.jpg b/examples/ssr/public/images/products/oats.jpg new file mode 100644 index 000000000..b8db72ae0 Binary files /dev/null and b/examples/ssr/public/images/products/oats.jpg differ diff --git a/examples/ssr/public/images/products/yogurt.jpg b/examples/ssr/public/images/products/yogurt.jpg new file mode 100644 index 000000000..9cd39666d Binary files /dev/null and b/examples/ssr/public/images/products/yogurt.jpg differ diff --git a/examples/ssr/server/api.mjs b/examples/ssr/server/api.mjs new file mode 100644 index 000000000..3928d0507 --- /dev/null +++ b/examples/ssr/server/api.mjs @@ -0,0 +1,49 @@ +import fs from 'fs'; +const dbJSON = fs.readFileSync(new URL('./db.json', import.meta.url)); +const db = JSON.parse(dbJSON); +const products = db.products; +const productMap = new Map(products.map(product => [product.id, product])); + +const routes = [ + { + match: /\/api\/products\/([0-9])+/, + async handle(_req, res, [,idStr]) { + const id = Number(idStr); + if(productMap.has(id)) { + const product = productMap.get(id); + res.writeHead(200, { + 'Content-Type': 'application/json' + }); + res.end(JSON.stringify(product)); + } else { + res.writeHead(404, { + 'Content-Type': 'text/plain' + }); + res.end('Not found'); + } + } + }, + { + match: /\/api\/products/, + async handle(_req, res) { + res.writeHead(200, { + 'Content-Type': 'application/json', + }); + res.end(JSON.stringify(products)); + } + } + +] + +export async function apiHandler(req, res) { + for(const route of routes) { + const match = route.match.exec(req.url); + if(match) { + return route.handle(req, res, match); + } + } + res.writeHead(404, { + 'Content-Type': 'text/plain' + }); + res.end('Not found'); +} diff --git a/examples/ssr/server/db.json b/examples/ssr/server/db.json new file mode 100644 index 000000000..76f9e4da3 --- /dev/null +++ b/examples/ssr/server/db.json @@ -0,0 +1,28 @@ +{ + "products": [ + { + "id": 1, + "name": "Cereal", + "price": 3.99, + "image": "/images/products/cereal.jpg" + }, + { + "id": 2, + "name": "Yogurt", + "price": 3.97, + "image": "/images/products/yogurt.jpg" + }, + { + "id": 3, + "name": "Rolled Oats", + "price": 2.89, + "image": "/images/products/oats.jpg" + }, + { + "id": 4, + "name": "Muffins", + "price": 4.39, + "image": "/images/products/muffins.jpg" + } + ] +} diff --git a/examples/ssr/server/dev-api.mjs b/examples/ssr/server/dev-api.mjs new file mode 100644 index 000000000..74e0ef83b --- /dev/null +++ b/examples/ssr/server/dev-api.mjs @@ -0,0 +1,17 @@ +import { createServer } from 'http'; +import { apiHandler } from './api.mjs'; + +const PORT = process.env.PORT || 8085; + +const server = createServer((req, res) => { + apiHandler(req, res).catch(err => { + console.error(err); + res.writeHead(500, { + 'Content-Type': 'text/plain' + }); + res.end(err.toString()); + }) +}); + +server.listen(PORT); +console.log(`API running at http://localhost:${PORT}`); diff --git a/examples/ssr/server/server.mjs b/examples/ssr/server/server.mjs new file mode 100644 index 000000000..6f0a0dea6 --- /dev/null +++ b/examples/ssr/server/server.mjs @@ -0,0 +1,55 @@ +import { createServer } from 'http'; +import fs from 'fs'; +import mime from 'mime'; +import { loadApp } from 'astro/app/node'; +import { polyfill } from '@astropub/webapi' +import { apiHandler } from './api.mjs'; + +polyfill(globalThis); + +const clientRoot = new URL('../dist/client/', import.meta.url); +const serverRoot = new URL('../dist/server/', import.meta.url); +const app = await loadApp(serverRoot); + +async function handle(req, res) { + const route = app.match(req); + + if(route) { + const html = await app.render(req, route); + + res.writeHead(200, { + 'Content-Type': 'text/html' + }); + res.end(html) + } else if(/^\/api\//.test(req.url)) { + return apiHandler(req, res); + } else { + let local = new URL('.' + req.url, clientRoot); + try { + const data = await fs.promises.readFile(local); + res.writeHead(200, { + 'Content-Type': mime.getType(req.url) + }); + res.end(data); + } catch { + res.writeHead(404); + res.end(); + } + } +} + +const server = createServer((req, res) => { + handle(req, res).catch(err => { + console.error(err); + res.writeHead(500, { + 'Content-Type': 'text/plain' + }); + res.end(err.toString()); + }) +}); + +server.listen(8085); +console.log('Serving at http://localhost:8085'); + +// Silence weird