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),
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[] {
serhalp marked this conversation as resolved.
Show resolved Hide resolved
const publicAssets = nitro.options.publicAssets.filter(
(dir) => dir.fallthrough !== true && dir.baseURL && dir.baseURL !== "/"
serhalp marked this conversation as resolved.
Show resolved Hide resolved
);
return ["/.netlify/*", ...publicAssets.map((dir) => `${dir.baseURL}/*`)];
pi0 marked this conversation as resolved.
Show resolved Hide resolved
}

// 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: {
serhalp marked this conversation as resolved.
Show resolved Hide resolved
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: "mock-framework@1.2.3",
path: "/*",
excludedPath: ["/.netlify/*","/icons/*","/nested/fonts/*","/build/*"],
pi0 marked this conversation as resolved.
Show resolved Hide resolved
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: [
serhalp marked this conversation as resolved.
Show resolved Hide resolved
{
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;
pi0 marked this conversation as resolved.
Show resolved Hide resolved
};
},
() => {
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: "mock-framework@1.2.3",
},
],
});
});
}
);
});
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 @@
"cloudflare-module",
"cloudflare-module-legacy",
"cloudflare-pages",
"netlify-edge",
"vercel-edge",
"winterjs",
].includes(preset),
Expand Down Expand Up @@ -225,7 +226,7 @@

it("API Works", async () => {
const { data: helloData } = await callHandler({ url: "/api/hello" });
expect(helloData).to.toMatchObject({ message: "Hello API" });

Check failure on line 229 in test/tests.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

test/presets/netlify.test.ts > nitro:preset:netlify-edge > API Works

AssertionError: expected undefined to match object { message: 'Hello API' } - Expected: Object { "message": "Hello API", } + Received: undefined ❯ test/tests.ts:229:26

if (ctx.nitro?.options.serveStatic) {
// /api/hey is expected to be prerendered
Expand Down
Loading