diff --git a/package.json b/package.json index 21eff76409..451fb5c44d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c345b4db4..26099eb6cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -232,6 +232,9 @@ importers: '@deno/types': specifier: ^0.0.1 version: 0.0.1 + '@netlify/edge-functions': + specifier: ^2.11.0 + version: 2.11.0 '@scalar/api-reference': specifier: ^1.25.46 version: 1.25.46(@hyperjump/browser@1.1.6)(tailwindcss@3.4.13)(typescript@5.6.3) @@ -1192,6 +1195,9 @@ packages: resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} hasBin: true + '@netlify/edge-functions@2.11.0': + resolution: {integrity: sha512-DZrDHdPX44Cj7T9geKdiIwHB6OScL7QFL10voC8TApgEwY/NhKfABBGF2cbUIfbAh3IAMBeikelT8PU0MqYnyg==} + '@netlify/functions@2.8.2': resolution: {integrity: sha512-DeoAQh8LuNPvBE4qsKlezjKj0PyXDryOFJfJKo3Z1qZLKzQ21sT314KQKPVjfvw6knqijj+IO+0kHXy/TJiqNA==} engines: {node: '>=14.0.0'} @@ -6921,6 +6927,8 @@ snapshots: - encoding - supports-color + '@netlify/edge-functions@2.11.0': {} + '@netlify/functions@2.8.2': dependencies: '@netlify/serverless-functions-api': 1.26.1 diff --git a/src/presets/netlify/preset.ts b/src/presets/netlify/preset.ts index be7379fb87..1ede327c68 100644 --- a/src/presets/netlify/preset.ts +++ b/src/presets/netlify/preset.ts @@ -6,6 +6,7 @@ import netlifyLegacyPresets from "./legacy/preset"; import { generateNetlifyFunction, getGeneratorString, + getStaticPaths, writeHeaders, writeRedirects, } from "./utils"; @@ -85,6 +86,7 @@ const netlifyEdge = defineNitroPreset( functions: [ { path: "/*", + excludedPath: getStaticPaths(nitro.options.publicAssets), name: "edge server handler", function: "server", generator: getGeneratorString(nitro), diff --git a/src/presets/netlify/runtime/netlify-edge.ts b/src/presets/netlify/runtime/netlify-edge.ts index 7d64738b20..09d85b2c8a 100644 --- a/src/presets/netlify/runtime/netlify-edge.ts +++ b/src/presets/netlify/runtime/netlify-edge.ts @@ -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)) { diff --git a/src/presets/netlify/utils.ts b/src/presets/netlify/utils.ts index b5c9d3ba8e..e1a7bd6c22 100644 --- a/src/presets/netlify/utils.ts +++ b/src/presets/netlify/utils.ts @@ -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"); @@ -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; @@ -103,6 +116,7 @@ export const config = { name: "server handler", generator: "${getGeneratorString(nitro)}", path: "/*", + excludedPath: ${JSON.stringify(getStaticPaths(nitro.options.publicAssets))}, preferStatic: true, }; `.trim(); diff --git a/test/fixture/nitro.config.ts b/test/fixture/nitro.config.ts index 097b9f27bf..4d050365d0 100644 --- a/test/fixture/nitro.config.ts +++ b/test/fixture/nitro.config.ts @@ -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: [ { diff --git a/test/presets/netlify.test.ts b/test/presets/netlify.test.ts index 900eb5d023..80b5d592b0 100644 --- a/test/presets/netlify.test.ts +++ b/test/presets/netlify.test.ts @@ -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 () => { @@ -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 }; + )) as { + default: (req: Request, _ctx: FunctionContext) => Promise; + }; return async ({ url: rawRelativeUrl, headers, method, body }) => { // creating new URL object to parse query easier const url = new URL(`https://example.com${rawRelativeUrl}`); @@ -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; }; }, @@ -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"), @@ -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") @@ -86,6 +91,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).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" }); @@ -93,6 +116,7 @@ describe("nitro:preset:netlify", async () => { (headers as Record)["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)["cache-control"]).toBe( @@ -100,6 +124,7 @@ describe("nitro:preset:netlify", async () => { ); }); }); + 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" }); @@ -109,6 +134,7 @@ 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)["cache-control"]).toBe( @@ -116,6 +142,7 @@ describe("nitro:preset:netlify", async () => { ); }); }); + it("does not overwrite Cache-Control headers given a matching non-ISR route rule", async () => { const { headers } = await callHandler({ url: "/rules/dynamic" }); expect( @@ -125,6 +152,7 @@ describe("nitro:preset:netlify", async () => { (headers as Record)["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({ @@ -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/*", + ]); + }); + }); }); diff --git a/test/tests.ts b/test/tests.ts index ccb94269df..7839c06808 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -88,6 +88,7 @@ export async function setupTest( "cloudflare-module", "cloudflare-module-legacy", "cloudflare-pages", + "netlify-edge", "vercel-edge", "winterjs", ].includes(preset), @@ -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