feat(@astrojs/netlify): add build.split support (#7615)

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
Co-authored-by: Bjorn Lu <bjornlu.dev@gmail.com>
This commit is contained in:
Emanuele Stoppa 2023-07-13 09:21:33 +01:00 committed by GitHub
parent b30a1bc2b8
commit f21357b69d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 234 additions and 35 deletions

View file

@ -0,0 +1,20 @@
---
'@astrojs/netlify': minor
---
The Netlify adapter builds to a single function by default. Astro 2.7 added support for splitting your build into separate entry points per page. If you use this configuration, the Netlify adapter will generate a separate function for each page. This can help reduce the size of each function so they are only bundling code used on that page.
```js
// astro.config.mjs
import { defineConfig } from 'astro/config';
import netlify from '@astrojs/netlify/functions';
export default defineConfig({
output: 'server',
adapter: netlify(),
build: {
split: true,
},
});
```

View file

@ -0,0 +1,5 @@
---
'@astrojs/underscore-redirects': minor
---
Refactor how the routes are passed.

View file

@ -264,10 +264,14 @@ export default function createIntegration(args?: Options): AstroIntegration {
} }
} }
const redirectRoutes = routes.filter((r) => r.type === 'redirect'); const redirectRoutes: [RouteData, string][] = routes
.filter((r) => r.type === 'redirect')
.map((r) => {
return [r, ''];
});
const trueRedirects = createRedirectsFromAstroRoutes({ const trueRedirects = createRedirectsFromAstroRoutes({
config: _config, config: _config,
routes: redirectRoutes, routeToDynamicTargetMap: new Map(Array.from(redirectRoutes)),
dir, dir,
}); });
if (!trueRedirects.empty()) { if (!trueRedirects.empty()) {

View file

@ -72,6 +72,24 @@ export default defineConfig({
}); });
``` ```
### Per-page functions
The Netlify adapter builds to a single function by default. Astro 2.7 added support for splitting your build into separate entry points per page. If you use this configuration, the Netlify adapter will generate a separate function for each page. This can help reduce the size of each function so they are only bundling code used on that page.
```js
// astro.config.mjs
import { defineConfig } from 'astro/config';
import netlify from '@astrojs/netlify/functions';
export default defineConfig({
output: 'server',
adapter: netlify(),
build: {
split: true,
},
});
```
### Static sites ### Static sites
For static sites you usually don't need an adapter. However, if you use `redirects` configuration (experimental) in your Astro config, the Netlify adapter can be used to translate this to the proper `_redirects` format. For static sites you usually don't need an adapter. However, if you use `redirects` configuration (experimental) in your Astro config, the Netlify adapter can be used to translate this to the proper `_redirects` format.

View file

@ -32,7 +32,7 @@
"build": "astro-scripts build \"src/**/*.ts\" && tsc", "build": "astro-scripts build \"src/**/*.ts\" && tsc",
"build:ci": "astro-scripts build \"src/**/*.ts\"", "build:ci": "astro-scripts build \"src/**/*.ts\"",
"dev": "astro-scripts dev \"src/**/*.ts\"", "dev": "astro-scripts dev \"src/**/*.ts\"",
"test-fn": "mocha --exit --timeout 20000 test/functions/", "test-fn": "mocha --exit --timeout 20000 --file \"./test/setup.js\" test/functions/",
"test-edge": "deno test --allow-run --allow-read --allow-net --allow-env ./test/edge-functions/", "test-edge": "deno test --allow-run --allow-read --allow-net --allow-env ./test/edge-functions/",
"test": "npm run test-fn" "test": "npm run test-fn"
}, },
@ -54,7 +54,8 @@
"chai": "^4.3.7", "chai": "^4.3.7",
"cheerio": "1.0.0-rc.12", "cheerio": "1.0.0-rc.12",
"mocha": "^9.2.2", "mocha": "^9.2.2",
"vite": "^4.3.9" "vite": "^4.3.9",
"chai-jest-snapshot": "^2.0.0"
}, },
"astro": { "astro": {
"external": true "external": true

View file

@ -166,7 +166,12 @@ export function netlifyEdgeFunctions({ dist }: NetlifyEdgeFunctionsOptions = {})
'astro:build:done': async ({ routes, dir }) => { 'astro:build:done': async ({ routes, dir }) => {
await bundleServerEntry(_buildConfig, _vite); await bundleServerEntry(_buildConfig, _vite);
await createEdgeManifest(routes, entryFile, _config.root); await createEdgeManifest(routes, entryFile, _config.root);
await createRedirects(_config, routes, dir, entryFile, 'edge-functions'); const dynamicTarget = `/.netlify/edge-functions/${entryFile}`;
const map: [RouteData, string][] = routes.map((route) => {
return [route, dynamicTarget];
});
const routeToDynamicTargetMap = new Map(Array.from(map));
await createRedirects(_config, routeToDynamicTargetMap, dir);
}, },
}, },
}; };

View file

@ -1,6 +1,8 @@
import type { AstroAdapter, AstroConfig, AstroIntegration } from 'astro'; import type { AstroAdapter, AstroConfig, AstroIntegration, RouteData } from 'astro';
import type { Args } from './netlify-functions.js'; import type { Args } from './netlify-functions.js';
import { createRedirects } from './shared.js'; import { createRedirects } from './shared.js';
import { fileURLToPath } from 'node:url';
import { extname } from 'node:path';
export function getAdapter(args: Args = {}): AstroAdapter { export function getAdapter(args: Args = {}): AstroAdapter {
return { return {
@ -23,7 +25,8 @@ function netlifyFunctions({
binaryMediaTypes, binaryMediaTypes,
}: NetlifyFunctionsOptions = {}): AstroIntegration { }: NetlifyFunctionsOptions = {}): AstroIntegration {
let _config: AstroConfig; let _config: AstroConfig;
let entryFile: string; let _entryPoints: Map<RouteData, URL>;
let ssrEntryFile: string;
return { return {
name: '@astrojs/netlify', name: '@astrojs/netlify',
hooks: { hooks: {
@ -37,10 +40,13 @@ function netlifyFunctions({
}, },
}); });
}, },
'astro:build:ssr': ({ entryPoints }) => {
_entryPoints = entryPoints;
},
'astro:config:done': ({ config, setAdapter }) => { 'astro:config:done': ({ config, setAdapter }) => {
setAdapter(getAdapter({ binaryMediaTypes, builders })); setAdapter(getAdapter({ binaryMediaTypes, builders }));
_config = config; _config = config;
entryFile = config.build.serverEntry.replace(/\.m?js/, ''); ssrEntryFile = config.build.serverEntry.replace(/\.m?js/, '');
if (config.output === 'static') { if (config.output === 'static') {
console.warn( console.warn(
@ -53,7 +59,32 @@ function netlifyFunctions({
}, },
'astro:build:done': async ({ routes, dir }) => { 'astro:build:done': async ({ routes, dir }) => {
const type = builders ? 'builders' : 'functions'; const type = builders ? 'builders' : 'functions';
await createRedirects(_config, routes, dir, entryFile, type); const kind = type ?? 'functions';
if (_entryPoints.size) {
const routeToDynamicTargetMap = new Map();
for (const [route, entryFile] of _entryPoints) {
const wholeFileUrl = fileURLToPath(entryFile);
const extension = extname(wholeFileUrl);
const relative = wholeFileUrl
.replace(fileURLToPath(_config.build.server), '')
.replace(extension, '')
.replaceAll('\\', '/');
const dynamicTarget = `/.netlify/${kind}/${relative}`;
routeToDynamicTargetMap.set(route, dynamicTarget);
}
await createRedirects(_config, routeToDynamicTargetMap, dir);
} else {
const dynamicTarget = `/.netlify/${kind}/${ssrEntryFile}`;
const map: [RouteData, string][] = routes.map((route) => {
return [route, dynamicTarget];
});
const routeToDynamicTargetMap = new Map(Array.from(map));
await createRedirects(_config, routeToDynamicTargetMap, dir);
}
}, },
}, },
}; };

View file

@ -1,4 +1,4 @@
import type { AstroIntegration } from 'astro'; import type { AstroIntegration, RouteData } from 'astro';
import { createRedirects } from './shared.js'; import { createRedirects } from './shared.js';
export function netlifyStatic(): AstroIntegration { export function netlifyStatic(): AstroIntegration {
@ -18,7 +18,12 @@ export function netlifyStatic(): AstroIntegration {
_config = config; _config = config;
}, },
'astro:build:done': async ({ dir, routes }) => { 'astro:build:done': async ({ dir, routes }) => {
await createRedirects(_config, routes, dir, '', 'static'); const mappedRoutes: [RouteData, string][] = routes.map((route) => [
route,
`/.netlify/static/`,
]);
const routesToDynamicTargetMap = new Map(Array.from(mappedRoutes));
await createRedirects(_config, routesToDynamicTargetMap, dir);
}, },
}, },
}; };

View file

@ -4,20 +4,15 @@ import fs from 'node:fs';
export async function createRedirects( export async function createRedirects(
config: AstroConfig, config: AstroConfig,
routes: RouteData[], routeToDynamicTargetMap: Map<RouteData, string>,
dir: URL, dir: URL
entryFile: string,
type: 'functions' | 'edge-functions' | 'builders' | 'static'
) { ) {
const kind = type ?? 'functions';
const dynamicTarget = `/.netlify/${kind}/${entryFile}`;
const _redirectsURL = new URL('./_redirects', dir); const _redirectsURL = new URL('./_redirects', dir);
const _redirects = createRedirectsFromAstroRoutes({ const _redirects = createRedirectsFromAstroRoutes({
config, config,
routes, routeToDynamicTargetMap,
dir, dir,
dynamicTarget,
}); });
const content = _redirects.print(); const content = _redirects.print();

View file

@ -1,8 +1,8 @@
<html> <html>
<head> <head>
<title>Testing</title> <title>Blog</title>
</head> </head>
<body> <body>
<h1>testing</h1> <h1>Blog</h1>
</body> </body>
</html> </html>

View file

@ -0,0 +1,8 @@
<html>
<head>
<title>Testing</title>
</head>
<body>
<h1>testing</h1>
</body>
</html>

View file

@ -0,0 +1,8 @@
<html>
<head>
<title>Blog</title>
</head>
<body>
<h1>Blog</h1>
</body>
</html>

View file

@ -46,5 +46,6 @@ describe('SSG - Redirects', () => {
'/.netlify/functions/entry', '/.netlify/functions/entry',
'200', '200',
]); ]);
expect(redirects).to.matchSnapshot();
}); });
}); });

View file

@ -0,0 +1,8 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SSG - Redirects Creates a redirects file 1`] = `
"/other / 301
/nope /.netlify/functions/entry 200
/ /.netlify/functions/entry 200
/team/articles/* /.netlify/functions/entry 200"
`;

View file

@ -0,0 +1,63 @@
import { expect } from 'chai';
import netlifyAdapter from '../../dist/index.js';
import { loadFixture, testIntegration } from './test-utils.js';
describe('Split support', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
let _entryPoints;
before(async () => {
fixture = await loadFixture({
root: new URL('./fixtures/split-support/', import.meta.url).toString(),
output: 'server',
adapter: netlifyAdapter({
dist: new URL('./fixtures/split-support/dist/', import.meta.url),
}),
site: `http://example.com`,
integrations: [
testIntegration({
setEntryPoints(ep) {
_entryPoints = ep;
},
}),
],
build: {
split: true,
},
});
await fixture.build();
});
it('outputs a correct redirect file', async () => {
const redir = await fixture.readFile('/_redirects');
const lines = redir.split(/[\r\n]+/);
expect(lines.length).to.equal(2);
expect(lines[0].includes('/blog')).to.be.true;
expect(lines[0].includes('blog.astro')).to.be.true;
expect(lines[0].includes('200')).to.be.true;
expect(lines[1].includes('/')).to.be.true;
expect(lines[1].includes('index.astro')).to.be.true;
expect(lines[1].includes('200')).to.be.true;
});
describe('Should create multiple functions', () => {
it('and hit 200', async () => {
if (_entryPoints) {
for (const [, filePath] of _entryPoints) {
const { handler } = await import(filePath.toString());
const resp = await handler({
httpMethod: 'POST',
headers: {},
rawUrl: 'http://example.com/',
body: '{}',
});
expect(resp.statusCode).to.equal(200);
}
} else {
expect(false).to.be.true;
}
});
});
});

View file

@ -7,7 +7,7 @@ export * from '../../../../astro/test/test-utils.js';
* *
* @returns {import('../../../../astro/dist/types/@types/astro').AstroIntegration} * @returns {import('../../../../astro/dist/types/@types/astro').AstroIntegration}
*/ */
export function testIntegration() { export function testIntegration({ setEntryPoints } = {}) {
return { return {
name: '@astrojs/netlify/test-integration', name: '@astrojs/netlify/test-integration',
hooks: { hooks: {
@ -24,6 +24,11 @@ export function testIntegration() {
}, },
}); });
}, },
'astro:build:ssr': ({ entryPoints }) => {
if (entryPoints.size) {
setEntryPoints(entryPoints);
}
},
}, },
}; };
} }

View file

@ -0,0 +1,12 @@
import { use } from 'chai';
import chaiJestSnapshot from 'chai-jest-snapshot';
use(chaiJestSnapshot);
before(function () {
chaiJestSnapshot.resetSnapshotRegistry();
});
beforeEach(function () {
chaiJestSnapshot.configureUsingMochaContext(this);
});

View file

@ -13,9 +13,11 @@ function getRedirectStatus(route: RouteData): ValidRedirectStatus {
interface CreateRedirectsFromAstroRoutesParams { interface CreateRedirectsFromAstroRoutesParams {
config: Pick<AstroConfig, 'build' | 'output'>; config: Pick<AstroConfig, 'build' | 'output'>;
routes: RouteData[]; /**
* Maps a `RouteData` to a dynamic target
*/
routeToDynamicTargetMap: Map<RouteData, string>;
dir: URL; dir: URL;
dynamicTarget?: string;
} }
/** /**
@ -23,18 +25,17 @@ interface CreateRedirectsFromAstroRoutesParams {
*/ */
export function createRedirectsFromAstroRoutes({ export function createRedirectsFromAstroRoutes({
config, config,
routes, routeToDynamicTargetMap,
dir, dir,
dynamicTarget = '',
}: CreateRedirectsFromAstroRoutesParams) { }: CreateRedirectsFromAstroRoutesParams) {
const output = config.output; const output = config.output;
const _redirects = new Redirects(); const _redirects = new Redirects();
for (const route of routes) { for (const [route, dynamicTarget = ''] of routeToDynamicTargetMap) {
// A route with a `pathname` is as static route. // A route with a `pathname` is as static route.
if (route.pathname) { if (route.pathname) {
if (route.redirect) { if (route.redirect) {
// A redirect route without dynamic parts. Get the redirect status // A redirect route without dynami§c parts. Get the redirect status
// from the user if provided. // from the user if provided.
_redirects.add({ _redirects.add({
dynamic: false, dynamic: false,

View file

@ -8,16 +8,22 @@ describe('Astro', () => {
}; };
it('Creates a Redirects object from routes', () => { it('Creates a Redirects object from routes', () => {
const routes = [ const routeToDynamicTargetMap = new Map(
{ pathname: '/', distURL: new URL('./index.html', import.meta.url), segments: [] }, Array.from([
{ pathname: '/one', distURL: new URL('./one/index.html', import.meta.url), segments: [] }, [
]; { pathname: '/', distURL: new URL('./index.html', import.meta.url), segments: [] },
const dynamicTarget = './.adapter/dist/entry.mjs'; './.adapter/dist/entry.mjs',
],
[
{ pathname: '/one', distURL: new URL('./one/index.html', import.meta.url), segments: [] },
'./.adapter/dist/entry.mjs',
],
])
);
const _redirects = createRedirectsFromAstroRoutes({ const _redirects = createRedirectsFromAstroRoutes({
config: serverConfig, config: serverConfig,
routes, routeToDynamicTargetMap,
dir: new URL(import.meta.url), dir: new URL(import.meta.url),
dynamicTarget,
}); });
expect(_redirects.definitions).to.have.a.lengthOf(2); expect(_redirects.definitions).to.have.a.lengthOf(2);

3
pnpm-lock.yaml generated
View file

@ -4431,6 +4431,9 @@ importers:
chai: chai:
specifier: ^4.3.7 specifier: ^4.3.7
version: 4.3.7 version: 4.3.7
chai-jest-snapshot:
specifier: ^2.0.0
version: 2.0.0(chai@4.3.7)
cheerio: cheerio:
specifier: 1.0.0-rc.12 specifier: 1.0.0-rc.12
version: 1.0.0-rc.12 version: 1.0.0-rc.12