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, netlify-edge): exclude static paths from server handler #2822

Merged
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.options.publicAssets),
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
16 changes: 15 additions & 1 deletion src/presets/netlify/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { existsSync, promises as fsp } from "node:fs";
import type { Nitro } from "nitropack/types";
import type { Nitro, PublicAssetDir } from "nitropack/types";
import { join } from "pathe";
import { joinURL } from "ufo";

export async function writeRedirects(nitro: Nitro) {
const redirectsPath = join(nitro.options.output.publicDir, "_redirects");
Expand Down Expand Up @@ -92,6 +93,18 @@ export async function writeHeaders(nitro: Nitro) {
await fsp.writeFile(headersPath, contents);
}

export function getStaticPaths(publicAssets: PublicAssetDir[]): string[] {
return [
"/.netlify",
...publicAssets
.filter(
(path) =>
path.fallthrough !== true && path.baseURL && path.baseURL !== "/"
)
.map(({ baseURL }) => baseURL),
].map((url) => joinURL("/", url!, "*"));
}

// 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 +116,7 @@ export const config = {
name: "server handler",
generator: "${getGeneratorString(nitro)}",
path: "/*",
excludedPath: ${JSON.stringify(getStaticPaths(nitro.options.publicAssets))},
preferStatic: true,
};
`.trim();
Expand Down
4 changes: 4 additions & 0 deletions test/fixture/nitro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { defineNitroConfig } from "nitropack/config";
export default defineNitroConfig({
compressPublicAssets: true,
compatibilityDate: "2024-09-19",
framework: {
name: "nitro",
version: "2.x",
},
imports: {
presets: [
{
Expand Down
83 changes: 80 additions & 3 deletions test/presets/netlify.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { promises as fsp } from "node:fs";
import type { Context } from "@netlify/functions";
import type { Context as FunctionContext } from "@netlify/functions";
import { resolve } from "pathe";
import { describe, expect, it } from "vitest";
import { getStaticPaths } from "../../src/presets/netlify/utils";
import { getPresetTmpDir, setupTest, testNitro } from "../tests";

describe("nitro:preset:netlify", async () => {
Expand All @@ -22,7 +23,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 +34,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 All @@ -51,6 +54,7 @@ describe("nitro:preset:netlify", async () => {
"
`);
});

it("adds route rules - headers", async () => {
const headers = await fsp.readFile(
resolve(ctx.outDir, "../dist/_headers"),
Expand All @@ -72,6 +76,7 @@ describe("nitro:preset:netlify", async () => {
"
`);
});

it("writes config.json", async () => {
const config = await fsp
.readFile(resolve(ctx.outDir, "../deploy/v1/config.json"), "utf8")
Expand All @@ -86,20 +91,40 @@ 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).toMatchInlineSnapshot(`
"export { default } from "./main.mjs";
export const config = {
name: "server handler",
generator: "[email protected]",
path: "/*",
excludedPath: ["/.netlify/*","/build/*"],
preferStatic: true,
};"
`);
});

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" });
expect(
(headers as Record<string, string>)["netlify-cdn-cache-control"]
).toBe("public, max-age=31536000, must-revalidate, durable");
});

it("sets Cache-Control header with immediate revalidation", async () => {
const { headers } = await callHandler({ url: "/rules/isr" });
expect((headers as Record<string, string>)["cache-control"]).toBe(
"public, max-age=0, must-revalidate"
);
});
});

describe("matching ISR route rule with a max-age", () => {
it("sets Netlify-CDN-Cache-Control header with SWC=1yr, given max-age, and durable directive", async () => {
const { headers } = await callHandler({ url: "/rules/isr-ttl" });
Expand All @@ -109,13 +134,15 @@ describe("nitro:preset:netlify", async () => {
"public, max-age=60, stale-while-revalidate=31536000, durable"
);
});

it("sets Cache-Control header with immediate revalidation", async () => {
const { headers } = await callHandler({ url: "/rules/isr-ttl" });
expect((headers as Record<string, string>)["cache-control"]).toBe(
"public, max-age=0, must-revalidate"
);
});
});

it("does not overwrite Cache-Control headers given a matching non-ISR route rule", async () => {
const { headers } = await callHandler({ url: "/rules/dynamic" });
expect(
Expand All @@ -125,6 +152,7 @@ describe("nitro:preset:netlify", async () => {
(headers as Record<string, string>)["netlify-cdn-cache-control"]
).not.toBeDefined();
});

// Regression test for https://github.com/unjs/nitro/issues/2431
it("matches paths with a query string", async () => {
const { headers } = await callHandler({
Expand All @@ -136,4 +164,53 @@ describe("nitro:preset:netlify", async () => {
});
}
);

describe("getStaticPaths", () => {
it("always returns `/.netlify/*`", () => {
expect(getStaticPaths([])).toEqual(["/.netlify/*"]);
});

it("returns a pattern with a leading slash for each non-fallthrough non-root public asset path", () => {
const publicAssets = [
{
fallthrough: true,
baseURL: "with-fallthrough",
dir: "with-fallthrough-dir",
maxAge: 0,
},
{
fallthrough: true,
dir: "with-fallthrough-no-baseURL-dir",
maxAge: 0,
},
{
fallthrough: false,
dir: "no-fallthrough-no-baseURL-dir",
maxAge: 0,
},
{
fallthrough: false,
dir: "no-fallthrough-root-baseURL-dir",
baseURL: "/",
maxAge: 0,
},
{
baseURL: "with-default-fallthrough",
dir: "with-default-fallthrough-dir",
maxAge: 0,
},
{
fallthrough: false,
baseURL: "nested/no-fallthrough",
dir: "nested/no-fallthrough-dir",
maxAge: 0,
},
];
expect(getStaticPaths(publicAssets)).toEqual([
"/.netlify/*",
"/with-default-fallthrough/*",
"/nested/no-fallthrough/*",
]);
});
});
});
3 changes: 2 additions & 1 deletion 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 Expand Up @@ -558,7 +559,7 @@ export function testNitro(
);

it.skipIf(ctx.isWorker || ctx.isDev)(
"public filesΒ can be un-ignored with patterns",
"public files can be un-ignored with patterns",
async () => {
expect((await callHandler({ url: "/_unignored.txt" })).status).toBe(
200
Expand Down