Skip to content

Commit

Permalink
perf(netlify, netlify-edge): exclude static paths from server handler (
Browse files Browse the repository at this point in the history
  • Loading branch information
serhalp authored Oct 31, 2024
1 parent 5a7b3d6 commit 3a24c3b
Show file tree
Hide file tree
Showing 8 changed files with 114 additions and 6 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.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: "nitro@2.x",
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

0 comments on commit 3a24c3b

Please sign in to comment.