Skip to content

Commit

Permalink
perf(netlify,netlify-edge): exclude static paths from SSR function
Browse files Browse the repository at this point in the history
The netlify preset was already using `preferStatic: true` which lets existing static
assets take precedence over the function, but this now also avoids a function invocation
for static paths that don't exist, i.e. this avoids an unnecessary dynamic 404 that could
be served directly from the CDN.

The `netlify-edge` preset wasn't excluding anything, so this addresses both the 404 case
and the existing asset case.

The 404 case is important because browsers frequently attempt to request hashed assets
from previous deploys that have been invalidated. There's no reason for this to go through
functions, since we know that the whole parent path is static.
  • Loading branch information
serhalp committed Oct 30, 2024
1 parent e3a2d6f commit ce72ea3
Show file tree
Hide file tree
Showing 7 changed files with 212 additions and 5 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@
"@azure/static-web-apps-cli": "^1.1.10",
"@cloudflare/workers-types": "^4.20241018.0",
"@deno/types": "^0.0.1",
"@netlify/edge-functions": "^2.11.0",
"@scalar/api-reference": "^1.25.46",
"@types/archiver": "^6.0.2",
"@types/aws-lambda": "^8.10.145",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/presets/netlify/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import netlifyLegacyPresets from "./legacy/preset";
import {
generateNetlifyFunction,
getGeneratorString,
getStaticPaths,
writeHeaders,
writeRedirects,
} from "./utils";
Expand Down Expand Up @@ -85,6 +86,7 @@ const netlifyEdge = defineNitroPreset(
functions: [
{
path: "/*",
excludedPath: getStaticPaths(nitro),
name: "edge server handler",
function: "server",
generator: getGeneratorString(nitro),
Expand Down
3 changes: 2 additions & 1 deletion src/presets/netlify/runtime/netlify-edge.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import "#nitro-internal-pollyfills";
import { useNitroApp } from "nitropack/runtime";
import { isPublicAssetURL } from "#nitro-internal-virtual/public-assets";
import type { Context } from "@netlify/edge-functions";

const nitroApp = useNitroApp();

// https://docs.netlify.com/edge-functions/api/
export default async function netlifyEdge(request: Request, _context: any) {
export default async function netlifyEdge(request: Request, _context: Context) {
const url = new URL(request.url);

if (isPublicAssetURL(url.pathname)) {
Expand Down
8 changes: 8 additions & 0 deletions src/presets/netlify/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ export async function writeHeaders(nitro: Nitro) {
await fsp.writeFile(headersPath, contents);
}

export function getStaticPaths(nitro: Nitro): string[] {
const publicAssets = nitro.options.publicAssets.filter(
(dir) => dir.fallthrough !== true && dir.baseURL && dir.baseURL !== "/"
);
return ["/.netlify/*", ...publicAssets.map((dir) => `${dir.baseURL}/*`)];
}

// This is written to the functions directory. It just re-exports the compiled handler,
// along with its config. We do this instead of compiling the entrypoint directly because
// the Netlify platform actually statically analyzes the function file to read the config;
Expand All @@ -103,6 +110,7 @@ export const config = {
name: "server handler",
generator: "${getGeneratorString(nitro)}",
path: "/*",
excludedPath: ${JSON.stringify(getStaticPaths(nitro))},
preferStatic: true,
};
`.trim();
Expand Down
194 changes: 190 additions & 4 deletions test/presets/netlify.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,44 @@
import { promises as fsp } from "node:fs";
import type { Context } from "@netlify/functions";
import type { Context as FunctionContext } from "@netlify/functions";
import type { Context as EdgeFunctionContext } from "@netlify/edge-functions";
import { resolve } from "pathe";
import { describe, expect, it } from "vitest";
import { getPresetTmpDir, setupTest, testNitro } from "../tests";

describe("nitro:preset:netlify", async () => {
const publicDir = resolve(getPresetTmpDir("netlify"), "dist");
const ctx = await setupTest("netlify", {
config: {
framework: {
name: "mock-framework",
version: "1.2.3",
},
publicAssets: [
{
fallthrough: true,
baseURL: "foo",
dir: publicDir,
},
{
fallthrough: false,
dir: publicDir,
},
{
fallthrough: true,
dir: publicDir,
},
{
baseURL: "icons",
dir: publicDir,
},
{
fallthrough: false,
baseURL: "nested/fonts",
dir: publicDir,
},
],
output: {
publicDir: resolve(getPresetTmpDir("netlify"), "dist"),
publicDir,
},
netlify: {
images: {
Expand All @@ -22,7 +52,9 @@ describe("nitro:preset:netlify", async () => {
async () => {
const { default: handler } = (await import(
resolve(ctx.outDir, "server/main.mjs")
)) as { default: (req: Request, ctx: Context) => Promise<Response> };
)) as {
default: (req: Request, _ctx: FunctionContext) => Promise<Response>;
};
return async ({ url: rawRelativeUrl, headers, method, body }) => {
// creating new URL object to parse query easier
const url = new URL(`https://example.com${rawRelativeUrl}`);
Expand All @@ -31,7 +63,7 @@ describe("nitro:preset:netlify", async () => {
method,
body,
});
const res = await handler(req, {} as Context);
const res = await handler(req, {} as FunctionContext);
return res;
};
},
Expand Down Expand Up @@ -86,6 +118,24 @@ describe("nitro:preset:netlify", async () => {
}
`);
});
it("writes server/server.mjs with static paths excluded", async () => {
const serverFunctionFile = await fsp.readFile(
resolve(ctx.outDir, "server/server.mjs"),
"utf8"
);
expect(serverFunctionFile).toEqual(
`
export { default } from "./main.mjs";
export const config = {
name: "server handler",
generator: "[email protected]",
path: "/*",
excludedPath: ["/.netlify/*","/icons/*","/nested/fonts/*","/build/*"],
preferStatic: true,
};
`.trim()
);
});
describe("matching ISR route rule with no max-age", () => {
it("sets Netlify-CDN-Cache-Control header with revalidation after 1 year and durable directive", async () => {
const { headers } = await callHandler({ url: "/rules/isr" });
Expand Down Expand Up @@ -137,3 +187,139 @@ describe("nitro:preset:netlify", async () => {
}
);
});

describe("nitro:preset:netlify-edge", async () => {
const publicDir = resolve(getPresetTmpDir("netlify-edge"), "dist");
const ctx = await setupTest("netlify-edge", {
config: {
framework: {
name: "mock-framework",
version: "1.2.3",
},
publicAssets: [
{
fallthrough: true,
baseURL: "foo",
dir: publicDir,
},
{
fallthrough: false,
dir: publicDir,
},
{
fallthrough: true,
dir: publicDir,
},
{
baseURL: "icons",
dir: publicDir,
},
{
fallthrough: false,
baseURL: "nested/fonts",
dir: publicDir,
},
],
output: {
publicDir,
},
netlify: {
images: {
remote_images: ["https://example.com/.*"],
},
},
},
});
testNitro(
ctx,
async () => {
const { default: handler } = (await import(
resolve(ctx.rootDir, ".netlify/edge-functions/server/server.js")
)) as {
default: (req: Request, _ctx: EdgeFunctionContext) => Promise<Response>;
};
return async ({ url: rawRelativeUrl, headers, method, body }) => {
// creating new URL object to parse query easier
const url = new URL(`https://example.com${rawRelativeUrl}`);
const req = new Request(url, {
headers: headers ?? {},
method,
body,
});
const res = await handler(req, {} as EdgeFunctionContext);
if (!(res instanceof Response))
// The Netlify Edge Function handler API allows returning `undefined` but this
// test helper only supports a Response or this shape.
return {
data: undefined,
status: 404,
headers: {},
};
return res;
};
},
() => {
it("adds route rules - redirects", async () => {
const redirects = await fsp.readFile(
resolve(ctx.outDir, "../dist/_redirects"),
"utf8"
);

expect(redirects).toMatchInlineSnapshot(`
"/rules/nested/override /other 302
/rules/redirect/wildcard/* https://nitro.unjs.io/:splat 302
/rules/redirect/obj https://nitro.unjs.io/ 301
/rules/nested/* /base 302
/rules/redirect /base 302
"
`);
});
it("adds route rules - headers", async () => {
const headers = await fsp.readFile(
resolve(ctx.outDir, "../dist/_headers"),
"utf8"
);

expect(headers).toMatchInlineSnapshot(`
"/rules/headers
cache-control: s-maxage=60
/rules/cors
access-control-allow-origin: *
access-control-allow-methods: GET
access-control-allow-headers: *
access-control-max-age: 0
/rules/nested/*
x-test: test
/build/*
cache-control: public, max-age=3600, immutable
"
`);
});
it("writes edge-functions/manifest.json with static paths excluded", async () => {
const manifestFile = JSON.parse(
await fsp.readFile(
resolve(ctx.rootDir, ".netlify/edge-functions/manifest.json"),
"utf8"
)
);
expect(manifestFile).toEqual({
version: 1,
functions: [
{
path: "/*",
excludedPath: [
"/.netlify/*",
"/icons/*",
"/nested/fonts/*",
"/build/*",
],
name: "edge server handler",
function: "server",
generator: "[email protected]",
},
],
});
});
}
);
});
1 change: 1 addition & 0 deletions test/tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export async function setupTest(
"cloudflare-module",
"cloudflare-module-legacy",
"cloudflare-pages",
"netlify-edge",
"vercel-edge",
"winterjs",
].includes(preset),
Expand Down

0 comments on commit ce72ea3

Please sign in to comment.