Skip to content
This repository has been archived by the owner on Feb 10, 2025. It is now read-only.

Commit

Permalink
perf(netlify): handle dependency tracing for SSR function (#296)
Browse files Browse the repository at this point in the history
* perf: take over function bundling

* Fix test failures

* Format

* Changeset

* Use shared fs helper

* Format

* Changes from review

* Add logging

* Test timeout

* Use posix path

* Remove logs
  • Loading branch information
ascorbic authored Jun 27, 2024
1 parent b3861c9 commit 8a00cad
Show file tree
Hide file tree
Showing 10 changed files with 1,082 additions and 279 deletions.
5 changes: 5 additions & 0 deletions .changeset/thick-kids-sing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/netlify': patch
---

Improves performance for serverless function builds by not bundling dependencies
10 changes: 6 additions & 4 deletions packages/netlify/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,26 +31,28 @@
"test:hosted": "astro-scripts test \"test/hosted/*.test.js\""
},
"dependencies": {
"@astrojs/internal-helpers": "0.4.1",
"@astrojs/underscore-redirects": "^0.3.3",
"@netlify/functions": "^2.0.1",
"@netlify/functions": "^2.8.0",
"@vercel/nft": "^0.27.2",
"esbuild": "^0.19.5"
},
"peerDependencies": {
"astro": "^4.2.0"
},
"devDependencies": {
"@astrojs/test-utils": "workspace:*",
"@netlify/edge-functions": "^2.0.0",
"@netlify/edge-handler-types": "^0.34.1",
"@types/node": "^18.17.8",
"astro": "^4.10.1",
"astro-scripts": "workspace:*",
"cheerio": "1.0.0-rc.12",
"execa": "^8.0.1",
"fast-glob": "^3.3.1",
"strip-ansi": "^7.1.0",
"typescript": "^5.2.2",
"vite": "^4.5.0",
"@astrojs/test-utils": "workspace:*",
"astro-scripts": "workspace:*"
"vite": "^4.5.0"
},
"astro": {
"external": true
Expand Down
3 changes: 2 additions & 1 deletion packages/netlify/src/functions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { AstroIntegration } from 'astro';
import netlifyIntegration, { type NetlifyIntegrationConfig } from './index.js';

export default function functionsIntegration(config: NetlifyIntegrationConfig) {
export default function functionsIntegration(config: NetlifyIntegrationConfig): AstroIntegration {
console.warn(
'The @astrojs/netlify/functions import is deprecated and will be removed in a future release. Please use @astrojs/netlify instead.'
);
Expand Down
54 changes: 43 additions & 11 deletions packages/netlify/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import { randomUUID } from 'node:crypto';
import { appendFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import type { IncomingMessage } from 'node:http';
import { fileURLToPath } from 'node:url';
import { emptyDir } from '@astrojs/internal-helpers/fs';
import { createRedirectsFromAstroRoutes } from '@astrojs/underscore-redirects';
import type { Context } from '@netlify/functions';
import type { AstroConfig, AstroIntegration, AstroIntegrationLogger, RouteData } from 'astro';
import { AstroError } from 'astro/errors';
import { build } from 'esbuild';
import { copyDependenciesToFunction } from './lib/nft.js';
import type { Args } from './ssr-function.js';

const { version: packageVersion } = JSON.parse(
Expand All @@ -22,8 +23,6 @@ export interface NetlifyLocals {
const isStaticRedirect = (route: RouteData) =>
route.type === 'redirect' && (route.redirect || route.redirectRoute);

const clearDirectory = (dir: URL) => rm(dir, { recursive: true }).catch(() => {});

type RemotePattern = AstroConfig['image']['remotePatterns'][number];

/**
Expand Down Expand Up @@ -190,11 +189,18 @@ export default function netlifyIntegration(
// Secret used to verify that the caller is the astro-generated edge middleware and not a third-party
const middlewareSecret = randomUUID();

const TRACE_CACHE = {};

const ssrBuildDir = () => new URL('./.netlify/build/', rootDir);
const ssrOutputDir = () => new URL('./.netlify/functions-internal/ssr/', rootDir);
const middlewareOutputDir = () => new URL('.netlify/edge-functions/middleware/', rootDir);

const cleanFunctions = async () =>
await Promise.all([clearDirectory(middlewareOutputDir()), clearDirectory(ssrOutputDir())]);
await Promise.all([
emptyDir(middlewareOutputDir()),
emptyDir(ssrOutputDir()),
emptyDir(ssrBuildDir()),
]);

async function writeRedirects(routes: RouteData[], dir: URL) {
const fallback = _config.output === 'static' ? '/.netlify/static' : '/.netlify/functions/ssr';
Expand All @@ -219,18 +225,45 @@ export default function netlifyIntegration(
}
}

async function writeSSRFunction(notFoundContent?: string) {
async function writeSSRFunction({
notFoundContent,
logger,
}: { notFoundContent?: string; logger: AstroIntegrationLogger }) {
const entry = new URL('./entry.mjs', ssrBuildDir());

const { handler } = await copyDependenciesToFunction(
{
entry,
outDir: ssrOutputDir(),
includeFiles: [],
excludeFiles: [],
logger,
},
TRACE_CACHE
);

await writeFile(
new URL('./ssr.mjs', ssrOutputDir()),
`
import createSSRHandler from './entry.mjs';
import createSSRHandler from './${handler}';
export default createSSRHandler(${JSON.stringify({
cacheOnDemandPages: Boolean(integrationConfig?.cacheOnDemandPages),
notFoundContent,
})});
export const config = { name: "Astro SSR", generator: "@astrojs/netlify@${packageVersion}", path: "/*", preferStatic: true };
`
);

await writeFile(
new URL('.netlify/functions-internal/ssr/ssr.json', rootDir),
JSON.stringify({
config: {
nodeBundler: 'none',
includedFiles: ['.netlify/functions-internal/ssr/**/*'],
},
version: 1,
})
);
}

async function writeMiddleware(entrypoint: URL) {
Expand Down Expand Up @@ -312,8 +345,6 @@ export default function netlifyIntegration(
city: 'Mock City',
country: { code: 'mock', name: 'Mock Country' },
subdivision: { code: 'SD', name: 'Mock Subdivision' },

// @ts-expect-error: these are smhw missing from the Netlify types - fix is on the way
timezone: 'UTC',
longitude: 0,
latitude: 0,
Expand All @@ -332,6 +363,8 @@ export default function netlifyIntegration(
get cookies(): never {
throw new Error('Please use Astro.cookies instead.');
},
// @ts-expect-error This is not currently included in the public Netlify types
flags: undefined,
json: (input) => Response.json(input),
log: console.log,
next: () => {
Expand Down Expand Up @@ -364,7 +397,7 @@ export default function netlifyIntegration(
build: {
redirects: false,
client: outDir,
server: ssrOutputDir(),
server: ssrBuildDir(),
},
vite: {
server: {
Expand Down Expand Up @@ -431,10 +464,9 @@ export default function netlifyIntegration(
try {
notFoundContent = await readFile(new URL('./404.html', dir), 'utf8');
} catch {}
await writeSSRFunction(notFoundContent);
await writeSSRFunction({ notFoundContent, logger });
logger.info('Generated SSR Function');
}

if (astroMiddlewareEntryPoint) {
await writeMiddleware(astroMiddlewareEntryPoint);
logger.info('Generated Middleware Edge Function');
Expand Down
85 changes: 85 additions & 0 deletions packages/netlify/src/lib/nft.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { posix, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { copyFilesToFolder } from '@astrojs/internal-helpers/fs';
import type { AstroIntegrationLogger } from 'astro';

// Based on the equivalent function in `@astrojs/vercel`
export async function copyDependenciesToFunction(
{
entry,
outDir,
includeFiles,
excludeFiles,
logger,
}: {
entry: URL;
outDir: URL;
includeFiles: URL[];
excludeFiles: URL[];
logger: AstroIntegrationLogger;
},
// we want to pass the caching by reference, and not by value
cache: object
): Promise<{ handler: string }> {
const entryPath = fileURLToPath(entry);
logger.info(`Bundling function ${relative(fileURLToPath(outDir), entryPath)}`);

// Get root of folder of the system (like C:\ on Windows or / on Linux)
let base = entry;
while (fileURLToPath(base) !== fileURLToPath(new URL('../', base))) {
base = new URL('../', base);
}

// The Vite bundle includes an import to `@vercel/nft` for some reason,
// and that trips up `@vercel/nft` itself during the adapter build. Using a
// dynamic import helps prevent the issue.
// TODO: investigate why
const { nodeFileTrace } = await import('@vercel/nft');
const result = await nodeFileTrace([entryPath], {
base: fileURLToPath(base),
// If you have a route of /dev this appears in source and NFT will try to
// scan your local /dev :8
ignore: ['/dev/**'],
cache,
});

for (const error of result.warnings) {
if (error.message.startsWith('Failed to resolve dependency')) {
const [, module, file] =
/Cannot find module '(.+?)' loaded from (.+)/.exec(error.message) || [];

// The import(astroRemark) sometimes fails to resolve, but it's not a problem
if (module === '@astrojs/') continue;

// Sharp is always external and won't be able to be resolved, but that's also not a problem
if (module === 'sharp') continue;

if (entryPath === file) {
logger.debug(
`The module "${module}" couldn't be resolved. This may not be a problem, but it's worth checking.`
);
} else {
logger.debug(
`The module "${module}" inside the file "${file}" couldn't be resolved. This may not be a problem, but it's worth checking.`
);
}
}
// parse errors are likely not js and can safely be ignored,
// such as this html file in "main" meant for nw instead of node:
// https://github.com/vercel/nft/issues/311
else if (!error.message.startsWith('Failed to parse')) {
throw error;
}
}

const commonAncestor = await copyFilesToFolder(
[...result.fileList].map((file) => new URL(file, base)).concat(includeFiles),
outDir,
excludeFiles
);

return {
// serverEntry location inside the outDir, converted to posix
handler: relative(commonAncestor, entryPath).split(sep).join(posix.sep),
};
}
86 changes: 46 additions & 40 deletions packages/netlify/test/functions/cookies.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,46 +2,52 @@ import * as assert from 'node:assert/strict';
import { before, describe, it } from 'node:test';
import { loadFixture } from '@astrojs/test-utils';

describe('Cookies', () => {
let fixture;
describe(
'Cookies',
() => {
let fixture;

before(async () => {
fixture = await loadFixture({ root: new URL('./fixtures/cookies/', import.meta.url) });
await fixture.build();
});
before(async () => {
fixture = await loadFixture({ root: new URL('./fixtures/cookies/', import.meta.url) });
await fixture.build();
});

it('Can set multiple', async () => {
const entryURL = new URL(
'./fixtures/cookies/.netlify/functions-internal/ssr/ssr.mjs',
import.meta.url
);
const { default: handler } = await import(entryURL);
const resp = await handler(
new Request('http://example.com/login', { method: 'POST', body: '{}' }),
{}
);
assert.equal(resp.status, 301);
assert.equal(resp.headers.get('location'), '/');
assert.deepEqual(resp.headers.getSetCookie(), ['foo=foo; HttpOnly', 'bar=bar; HttpOnly']);
});
it('Can set multiple', async () => {
const entryURL = new URL(
'./fixtures/cookies/.netlify/functions-internal/ssr/ssr.mjs',
import.meta.url
);
const { default: handler } = await import(entryURL);
const resp = await handler(
new Request('http://example.com/login', { method: 'POST', body: '{}' }),
{}
);
assert.equal(resp.status, 301);
assert.equal(resp.headers.get('location'), '/');
assert.deepEqual(resp.headers.getSetCookie(), ['foo=foo; HttpOnly', 'bar=bar; HttpOnly']);
});

it('renders dynamic 404 page', async () => {
const entryURL = new URL(
'./fixtures/cookies/.netlify/functions-internal/ssr/ssr.mjs',
import.meta.url
);
const { default: handler } = await import(entryURL);
const resp = await handler(
new Request('http://example.com/nonexistant-page', {
headers: {
'x-test': 'bar',
},
}),
{}
);
assert.equal(resp.status, 404);
const text = await resp.text();
assert.equal(text.includes('This is my custom 404 page'), true);
assert.equal(text.includes('x-test: bar'), true);
});
});
it('renders dynamic 404 page', async () => {
const entryURL = new URL(
'./fixtures/cookies/.netlify/functions-internal/ssr/ssr.mjs',
import.meta.url
);
const { default: handler } = await import(entryURL);
const resp = await handler(
new Request('http://example.com/nonexistant-page', {
headers: {
'x-test': 'bar',
},
}),
{}
);
assert.equal(resp.status, 404);
const text = await resp.text();
assert.equal(text.includes('This is my custom 404 page'), true);
assert.equal(text.includes('x-test: bar'), true);
});
},
{
timeout: 120000,
}
);
Loading

0 comments on commit 8a00cad

Please sign in to comment.