diff --git a/.changeset/blue-rocks-smoke.md b/.changeset/blue-rocks-smoke.md new file mode 100644 index 000000000..bb3bbbf1c --- /dev/null +++ b/.changeset/blue-rocks-smoke.md @@ -0,0 +1,18 @@ +--- +'astro': patch +'@astrojs/netlify': patch +'@astrojs/node': patch +--- + +Netlify Adapter + +This change adds a Netlify adapter that uses Netlify Functions. You can use it like so: + +```js +import { defineConfig } from 'astro/config'; +import netlify from '@astrojs/netlify'; + +export default defineConfig({ + adapter: netlify() +}); +``` diff --git a/packages/astro/package.json b/packages/astro/package.json index f6ce9f493..23926f8d0 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -15,6 +15,9 @@ "types": "./dist/types/@types/astro.d.ts", "typesVersions": { "*": { + "app": [ + "./dist/types/core/app/index" + ], "app/*": [ "./dist/types/core/app/*" ] diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 9e83af22d..988bec9b0 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -39,6 +39,9 @@ export interface CLIFlags { } export interface BuildConfig { + client: URL; + server: URL; + serverEntry: string; staticMode: boolean | undefined; } @@ -617,6 +620,7 @@ export interface AstroAdapter { name: string; serverEntrypoint?: string; exports?: string[]; + args?: any; } export interface EndpointOutput { @@ -670,7 +674,7 @@ export interface AstroIntegration { 'astro:server:start'?: (options: { address: AddressInfo }) => void | Promise; 'astro:server:done'?: () => void | Promise; 'astro:build:start'?: (options: { buildConfig: BuildConfig }) => void | Promise; - 'astro:build:done'?: (options: { pages: { pathname: string }[]; dir: URL }) => void | Promise; + 'astro:build:done'?: (options: { pages: { pathname: string }[]; dir: URL; routes: RouteData[] }) => void | Promise; }; } diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index ea4bd9cc0..0d395c776 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -27,3 +27,5 @@ export interface SSRManifest { export type SerializedSSRManifest = Omit & { routes: SerializedRouteInfo[]; }; + +export type AdapterCreateExports = (manifest: SSRManifest, args?: T) => Record; diff --git a/packages/astro/src/core/build/common.ts b/packages/astro/src/core/build/common.ts index 074170762..5407f66fd 100644 --- a/packages/astro/src/core/build/common.ts +++ b/packages/astro/src/core/build/common.ts @@ -1,5 +1,4 @@ import type { AstroConfig, RouteType } from '../../@types/astro'; -import type { StaticBuildOptions } from './types'; import npath from 'path'; import { appendForwardSlash } from '../../core/path.js'; @@ -9,18 +8,6 @@ export function getOutRoot(astroConfig: AstroConfig): URL { return new URL('./', astroConfig.dist); } -export function getServerRoot(astroConfig: AstroConfig): URL { - const rootFolder = getOutRoot(astroConfig); - const serverFolder = new URL('./server/', rootFolder); - return serverFolder; -} - -export function getClientRoot(astroConfig: AstroConfig): URL { - const rootFolder = getOutRoot(astroConfig); - const serverFolder = new URL('./client/', rootFolder); - return serverFolder; -} - export function getOutFolder(astroConfig: AstroConfig, pathname: string, routeType: RouteType): URL { const outRoot = getOutRoot(astroConfig); diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index b4e77a9e0..119274e76 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -13,7 +13,7 @@ import { BEFORE_HYDRATION_SCRIPT_ID } from '../../vite-plugin-scripts/index.js'; import { call as callEndpoint } from '../endpoint/index.js'; import { render } from '../render/core.js'; import { createLinkStylesheetElementSet, createModuleScriptElementWithSrcSet } from '../render/ssr-element.js'; -import { getOutFile, getOutRoot, getOutFolder, getServerRoot } from './common.js'; +import { getOutFile, getOutRoot, getOutFolder } from './common.js'; import { getPageDataByComponent, eachPageData } from './internal.js'; import { bgMagenta, black, cyan, dim, magenta } from 'kleur/colors'; import { getTimeStat } from './util.js'; @@ -70,8 +70,9 @@ export async function generatePages(result: RollupOutput, opts: StaticBuildOptio info(opts.logging, null, `\n${bgMagenta(black(' generating static routes '))}\n`); const ssr = !!opts.astroConfig._ctx.adapter?.serverEntrypoint; - const outFolder = ssr ? getServerRoot(opts.astroConfig) : getOutRoot(opts.astroConfig); - const ssrEntryURL = new URL(`./entry.mjs?time=${Date.now()}`, outFolder); + const serverEntry = opts.buildConfig.serverEntry; + const outFolder = ssr ? opts.buildConfig.server : opts.astroConfig.dist; + const ssrEntryURL = new URL('./' + serverEntry + `?time=${Date.now()}`, outFolder); const ssrEntry = await import(ssrEntryURL.toString()); for (const pageData of eachPageData(internals)) { diff --git a/packages/astro/src/core/build/index.ts b/packages/astro/src/core/build/index.ts index d830eb88d..14950b2f9 100644 --- a/packages/astro/src/core/build/index.ts +++ b/packages/astro/src/core/build/index.ts @@ -7,7 +7,7 @@ import { apply as applyPolyfill } from '../polyfill.js'; import { performance } from 'perf_hooks'; import * as vite from 'vite'; import { createVite, ViteConfigWithSSR } from '../create-vite.js'; -import { debug, defaultLogOptions, info, levels, timerMessage, warn } from '../logger.js'; +import { debug, defaultLogOptions, info, levels, timerMessage, warn, warnIfUsingExperimentalSSR } from '../logger.js'; import { createRouteManifest } from '../routing/index.js'; import { generateSitemap } from '../render/sitemap.js'; import { collectPagesData } from './page-data.js'; @@ -73,11 +73,17 @@ class AstroBuilder { { astroConfig: this.config, logging, mode: 'build' } ); await runHookConfigDone({ config: this.config }); + warnIfUsingExperimentalSSR(logging, this.config); this.viteConfig = viteConfig; const viteServer = await vite.createServer(viteConfig); this.viteServer = viteServer; debug('build', timerMessage('Vite started', timer.viteStart)); - const buildConfig: BuildConfig = { staticMode: undefined }; + const buildConfig: BuildConfig = { + client: new URL('./client/', this.config.dist), + server: new URL('./server/', this.config.dist), + serverEntry: 'entry.mjs', + staticMode: undefined + }; await runHookBuildStart({ config: this.config, buildConfig }); info(this.logging, 'build', 'Collecting page data...'); @@ -167,7 +173,7 @@ class AstroBuilder { // You're done! Time to clean up. await viteServer.close(); - await runHookBuildDone({ config: this.config, pages: pageNames }); + await runHookBuildDone({ config: this.config, pages: pageNames, routes: Object.values(allPages).map(pd => pd.route) }); if (logging.level && levels[logging.level] <= levels['info']) { const buildMode = this.config.buildOptions.experimentalSsr ? 'ssr' : 'static'; diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index 337bc1d59..2e54891d3 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -19,7 +19,6 @@ import { vitePluginSSR } from './vite-plugin-ssr.js'; import { vitePluginPages } from './vite-plugin-pages.js'; import { generatePages } from './generate.js'; import { trackPageData } from './internal.js'; -import { getClientRoot, getServerRoot, getOutRoot } from './common.js'; import { isBuildingToSSR } from '../util.js'; import { getTimeStat } from './util.js'; @@ -114,7 +113,7 @@ export async function staticBuild(opts: StaticBuildOptions) { async function ssrBuild(opts: StaticBuildOptions, internals: BuildInternals, input: Set) { const { astroConfig, viteConfig } = opts; const ssr = astroConfig.buildOptions.experimentalSsr; - const out = ssr ? getServerRoot(astroConfig) : getOutRoot(astroConfig); + const out = ssr ? opts.buildConfig.server : astroConfig.dist; // TODO: use vite.mergeConfig() here? return await vite.build({ logLevel: 'error', @@ -130,9 +129,10 @@ async function ssrBuild(opts: StaticBuildOptions, internals: BuildInternals, inp input: [], output: { format: 'esm', - entryFileNames: 'entry.mjs', + entryFileNames: opts.buildConfig.serverEntry, chunkFileNames: 'chunks/chunk.[hash].mjs', assetFileNames: 'assets/asset.[hash][extname]', + inlineDynamicImports: true, }, }, // must match an esbuild target @@ -170,12 +170,11 @@ async function clientBuild(opts: StaticBuildOptions, internals: BuildInternals, return null; } - const out = astroConfig.buildOptions.experimentalSsr ? getClientRoot(astroConfig) : getOutRoot(astroConfig); - // TODO: use vite.mergeConfig() here? - info(opts.logging, null, `\n${bgGreen(black(' building resources '))}\n`); + const out = isBuildingToSSR(astroConfig) ? opts.buildConfig.client : astroConfig.dist; + const buildResult = await vite.build({ logLevel: 'info', mode: 'production', @@ -229,9 +228,8 @@ async function cleanSsrOutput(opts: StaticBuildOptions) { async function ssrMoveAssets(opts: StaticBuildOptions) { info(opts.logging, 'build', 'Rearranging server assets...'); - const { astroConfig } = opts; - const serverRoot = getServerRoot(astroConfig); - const clientRoot = getClientRoot(astroConfig); + const serverRoot = opts.buildConfig.staticMode ? opts.buildConfig.client : opts.buildConfig.server; + const clientRoot = opts.buildConfig.client; const serverAssets = new URL('./assets/', serverRoot); const clientAssets = new URL('./assets/', clientRoot); const files = await glob('assets/**/*', { diff --git a/packages/astro/src/core/build/vite-plugin-ssr.ts b/packages/astro/src/core/build/vite-plugin-ssr.ts index 692d21741..efa54cc01 100644 --- a/packages/astro/src/core/build/vite-plugin-ssr.ts +++ b/packages/astro/src/core/build/vite-plugin-ssr.ts @@ -35,23 +35,24 @@ const _manifest = Object.assign(_deserializeManifest('${manifestReplace}'), { pageMap: _main.pageMap, renderers: _main.renderers }); +const _args = ${adapter.args ? JSON.stringify(adapter.args) : 'undefined'}; ${ adapter.exports - ? `const _exports = adapter.createExports(_manifest); + ? `const _exports = adapter.createExports(_manifest, _args); ${adapter.exports.map((name) => `export const ${name} = _exports['${name}'];`).join('\n')} ` : '' } const _start = 'start'; if(_start in adapter) { - adapter[_start](_manifest); + adapter[_start](_manifest, _args); }`; } return void 0; }, - generateBundle(opts, bundle) { + generateBundle(_opts, bundle) { const manifest = buildManifest(buildOpts, internals); for (const [_chunkName, chunk] of Object.entries(bundle)) { diff --git a/packages/astro/src/core/dev/index.ts b/packages/astro/src/core/dev/index.ts index ca34b0b15..166009cda 100644 --- a/packages/astro/src/core/dev/index.ts +++ b/packages/astro/src/core/dev/index.ts @@ -4,7 +4,7 @@ import * as vite from 'vite'; import type { AstroConfig } from '../../@types/astro'; import { runHookConfigDone, runHookConfigSetup, runHookServerDone, runHookServerSetup, runHookServerStart } from '../../integrations/index.js'; import { createVite } from '../create-vite.js'; -import { defaultLogOptions, info, LogOptions, warn } from '../logger.js'; +import { defaultLogOptions, info, LogOptions, warn, warnIfUsingExperimentalSSR } from '../logger.js'; import * as msg from '../messages.js'; import { apply as applyPolyfill } from '../polyfill.js'; import { getResolvedHostForVite } from './util.js'; @@ -32,6 +32,7 @@ export default async function dev(config: AstroConfig, options: DevOptions = { l { astroConfig: config, logging: options.logging, mode: 'dev' } ); await runHookConfigDone({ config }); + warnIfUsingExperimentalSSR(options.logging, config); const viteServer = await vite.createServer(viteConfig); runHookServerSetup({ config, server: viteServer }); await viteServer.listen(config.devOptions.port); diff --git a/packages/astro/src/core/logger.ts b/packages/astro/src/core/logger.ts index 19f07316a..75a1e36fe 100644 --- a/packages/astro/src/core/logger.ts +++ b/packages/astro/src/core/logger.ts @@ -1,3 +1,4 @@ +import type { AstroConfig } from '../@types/astro'; import { bold, cyan, dim, red, yellow, reset } from 'kleur/colors'; import { performance } from 'perf_hooks'; import { Writable } from 'stream'; @@ -5,6 +6,7 @@ import stringWidth from 'string-width'; import * as readline from 'readline'; import debugPackage from 'debug'; import { format as utilFormat } from 'util'; +import { isBuildingToSSR } from './util.js'; type ConsoleStream = Writable & { fd: 1 | 2; @@ -211,3 +213,12 @@ export function timerMessage(message: string, startTime: number = performance.no let timeDisplay = timeDiff < 750 ? `${Math.round(timeDiff)}ms` : `${(timeDiff / 1000).toFixed(1)}s`; return `${message} ${dim(timeDisplay)}`; } + +/** + * A warning that SSR is experimental. Remove when we can. + */ +export function warnIfUsingExperimentalSSR(opts: LogOptions, config: AstroConfig) { + if(isBuildingToSSR(config)) { + warn(opts, 'warning', bold(`Warning:`), ` SSR support is still experimental and subject to API changes. If using in production pin your dependencies to prevent accidental breakage.`); + } +} diff --git a/packages/astro/src/integrations/index.ts b/packages/astro/src/integrations/index.ts index 788a8d179..15ab427ea 100644 --- a/packages/astro/src/integrations/index.ts +++ b/packages/astro/src/integrations/index.ts @@ -1,6 +1,6 @@ import type { AddressInfo } from 'net'; import type { ViteDevServer } from 'vite'; -import { AstroConfig, AstroRenderer, BuildConfig } from '../@types/astro.js'; +import { AstroConfig, AstroRenderer, BuildConfig, RouteData } from '../@types/astro.js'; import { mergeConfig } from '../core/config.js'; import ssgAdapter from '../adapter-ssg/index.js'; @@ -91,10 +91,10 @@ export async function runHookBuildStart({ config, buildConfig }: { config: Astro } } -export async function runHookBuildDone({ config, pages }: { config: AstroConfig; pages: string[] }) { +export async function runHookBuildDone({ config, pages, routes }: { config: AstroConfig; pages: string[], routes: RouteData[] }) { for (const integration of config.integrations) { if (integration.hooks['astro:build:done']) { - await integration.hooks['astro:build:done']({ pages: pages.map((p) => ({ pathname: p })), dir: config.dist }); + await integration.hooks['astro:build:done']({ pages: pages.map((p) => ({ pathname: p })), dir: config.dist, routes }); } } } diff --git a/packages/integrations/netlify/package.json b/packages/integrations/netlify/package.json new file mode 100644 index 000000000..e3f1fd0ec --- /dev/null +++ b/packages/integrations/netlify/package.json @@ -0,0 +1,34 @@ +{ + "name": "@astrojs/netlify", + "description": "Deploy your site to Netlify", + "version": "0.0.1", + "type": "module", + "types": "./dist/index.d.ts", + "author": "withastro", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/withastro/astro.git", + "directory": "packages/integrations/netlify" + }, + "bugs": "https://github.com/withastro/astro/issues", + "homepage": "https://astro.build", + "exports": { + ".": "./dist/index.js", + "./functions": "./dist/integration-functions.js", + "./netlify-functions.js": "./dist/netlify-functions.js", + "./package.json": "./package.json" + }, + "scripts": { + "build": "astro-scripts build \"src/**/*.ts\" && tsc", + "dev": "astro-scripts dev \"src/**/*.ts\"" + }, + "dependencies": { + "@astrojs/webapi": "^0.11.0" + }, + "devDependencies": { + "@netlify/functions": "^1.0.0", + "astro": "workspace:*", + "astro-scripts": "workspace:*" + } +} diff --git a/packages/integrations/netlify/readme.md b/packages/integrations/netlify/readme.md new file mode 100644 index 000000000..24fdb5187 --- /dev/null +++ b/packages/integrations/netlify/readme.md @@ -0,0 +1,44 @@ +# @astrojs/netlify + +Deploy your server-side rendered (SSR) Astro app to [Netlify](https://www.netlify.com/). + +Use this adapter in your Astro configuration file: + +```js +import { defineConfig } from 'astro/config'; +import netlify from '@astrojs/netlify/functions'; + +export default defineConfig({ + adapter: netlify() +}); +``` + +After you build your site the `netlify/` folder will contain [Netlify Functions](https://docs.netlify.com/functions/overview/) in the `netlify/functions/` folder. + +Now you can deploy! + +```shell +netlify deploy +``` + +## Configuration + +The output folder is configuration with the `dist` property when creating the adapter. + +```js +import { defineConfig } from 'astro/config'; +import netlify from '@astrojs/netlify/functions'; + +export default defineConfig({ + adapter: netlify({ + dist: new URL('./dist/', import.meta.url) + }) +}); +``` + +And then point to the dist in your `netlify.toml`: + +```toml +[functions] + directory = "dist/functions" +``` diff --git a/packages/integrations/netlify/src/index.ts b/packages/integrations/netlify/src/index.ts new file mode 100644 index 000000000..4ddacfaf2 --- /dev/null +++ b/packages/integrations/netlify/src/index.ts @@ -0,0 +1,64 @@ +import type { AstroAdapter, AstroIntegration, AstroConfig } from 'astro'; +import fs from 'fs'; + +export function getAdapter(site: string | undefined): AstroAdapter { + return { + name: '@astrojs/netlify', + serverEntrypoint: '@astrojs/netlify/netlify-functions.js', + exports: ['handler'], + args: { site } + }; +} + +interface NetlifyFunctionsOptions { + dist?: URL; +} + +function netlifyFunctions({ dist }: NetlifyFunctionsOptions = {}): AstroIntegration { + let _config: AstroConfig; + let entryFile: string; + return { + name: '@astrojs/netlify', + hooks: { + 'astro:config:setup': ({ config }) => { + if(dist) { + config.dist = dist; + } else { + config.dist = new URL('./netlify/', config.projectRoot); + } + }, + 'astro:config:done': ({ config, setAdapter }) => { + setAdapter(getAdapter(config.buildOptions.site)); + _config = config; + }, + 'astro:build:start': async({ buildConfig }) => { + entryFile = buildConfig.serverEntry.replace(/\.m?js/, ''); + buildConfig.client = _config.dist; + buildConfig.server = new URL('./functions/', _config.dist); + }, + 'astro:build:done': async ({ routes, dir }) => { + const _redirectsURL = new URL('./_redirects', dir); + + // Create the redirects file that is used for routing. + let _redirects = ''; + for(const route of routes) { + if(route.pathname) { + _redirects += ` +${route.pathname} /.netlify/functions/${entryFile} 200` + } + } + + if(fs.existsSync(_redirects)) { + await fs.promises.appendFile(_redirectsURL, _redirects, 'utf-8'); + } else { + await fs.promises.writeFile(_redirectsURL, _redirects, 'utf-8'); + } + } + }, + }; +} + +export { + netlifyFunctions, + netlifyFunctions as default +}; diff --git a/packages/integrations/netlify/src/integration-functions.ts b/packages/integrations/netlify/src/integration-functions.ts new file mode 100644 index 000000000..540fcdee2 --- /dev/null +++ b/packages/integrations/netlify/src/integration-functions.ts @@ -0,0 +1,3 @@ +export { + netlifyFunctions as default +} from './index.js'; diff --git a/packages/integrations/netlify/src/netlify-functions.ts b/packages/integrations/netlify/src/netlify-functions.ts new file mode 100644 index 000000000..cf6c871a9 --- /dev/null +++ b/packages/integrations/netlify/src/netlify-functions.ts @@ -0,0 +1,43 @@ +import { SSRManifest } from 'astro'; +import type { Handler } from "@netlify/functions"; +import { App } from 'astro/app'; +import { polyfill } from '@astrojs/webapi'; + +polyfill(globalThis, { + exclude: 'window document', +}); + +interface Args { + site?: string; +} + +export const createExports = (manifest: SSRManifest, args: Args) => { + const app = new App(manifest); + const site = new URL(args.site ?? `https://netlify.com`); + + const handler: Handler = async (event) => { + const headers = new Headers(event.headers as any); + const request = new Request(new URL(event.path, site).toString(), { + method: event.httpMethod, + headers + }); + + if(!app.match(request)) { + return { + statusCode: 404, + body: 'Not found' + }; + } + + const response = await app.render(request); + const body = await response.text(); + + return { + statusCode: 200, + headers: Object.fromEntries(response.headers.entries()), + body + }; + } + + return { handler }; +}; diff --git a/packages/integrations/netlify/tsconfig.json b/packages/integrations/netlify/tsconfig.json new file mode 100644 index 000000000..44baf375c --- /dev/null +++ b/packages/integrations/netlify/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["src"], + "compilerOptions": { + "allowJs": true, + "module": "ES2020", + "outDir": "./dist", + "target": "ES2020" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0da432e9..20e1d39d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1177,6 +1177,19 @@ importers: astro: link:../../astro astro-scripts: link:../../../scripts + packages/integrations/netlify: + specifiers: + '@astrojs/webapi': ^0.11.0 + '@netlify/functions': ^1.0.0 + astro: workspace:* + astro-scripts: workspace:* + dependencies: + '@astrojs/webapi': link:../../webapi + devDependencies: + '@netlify/functions': 1.0.0 + astro: link:../../astro + astro-scripts: link:../../../scripts + packages/integrations/node: specifiers: '@astrojs/webapi': ^0.11.0 @@ -3413,6 +3426,13 @@ packages: vue: 3.2.31 dev: false + /@netlify/functions/1.0.0: + resolution: {integrity: sha512-7fnJv3vr8uyyyOYPChwoec6MjzsCw1CoRUO2DhQ1BD6bOyJRlD4DUaOOGlMILB2LCT8P24p5LexEGx8AJb7xdA==} + engines: {node: '>=8.3.0'} + dependencies: + is-promise: 4.0.0 + dev: true + /@nodelib/fs.scandir/2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -7002,6 +7022,10 @@ packages: engines: {node: '>=0.10.0'} dev: true + /is-promise/4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + dev: true + /is-regex/1.1.4: resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} engines: {node: '>= 0.4'}