Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

perf(netlify): handle dependency tracing for SSR function #296

Merged
merged 11 commits into from
Jun 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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