diff --git a/e2e-tests/adapters/cypress/e2e/basics.cy.ts b/e2e-tests/adapters/cypress/e2e/basics.cy.ts index 28bdb3c31c044..a3f0ef00b455f 100644 --- a/e2e-tests/adapters/cypress/e2e/basics.cy.ts +++ b/e2e-tests/adapters/cypress/e2e/basics.cy.ts @@ -1,18 +1,12 @@ import { title } from "../../constants" +import { WorkaroundCachedResponse } from "../utils/dont-cache-responses-in-browser" describe("Basics", () => { beforeEach(() => { cy.intercept("/gatsby-icon.png").as("static-folder-image") - cy.intercept("/static/astro-**.png", req => { - req.on("before:response", res => { - // this generally should be permamently cached, but that cause problems with intercepting - // see https://docs.cypress.io/api/commands/intercept#cyintercept-and-request-caching - // so we disable caching for this response - // tests for cache-control headers should be done elsewhere - - res.headers["cache-control"] = "no-store" - }) - }).as("img-import") + cy.intercept("/static/astro-**.png", WorkaroundCachedResponse).as( + "img-import" + ) cy.visit("/").waitForRouteChange() }) diff --git a/e2e-tests/adapters/cypress/e2e/headers.cy.ts b/e2e-tests/adapters/cypress/e2e/headers.cy.ts new file mode 100644 index 0000000000000..6ff0002d19ce6 --- /dev/null +++ b/e2e-tests/adapters/cypress/e2e/headers.cy.ts @@ -0,0 +1,147 @@ +import { WorkaroundCachedResponse } from "../utils/dont-cache-responses-in-browser" + +describe("Headers", () => { + const defaultHeaders = { + "x-xss-protection": "1; mode=block", + "x-content-type-options": "nosniff", + "referrer-policy": "same-origin", + "x-frame-options": "DENY", + } + + // DRY for repeated assertions in multple tests + const expectedHeadersByRouteAlias = { + "@app-data": { + ...defaultHeaders, + "cache-control": "public,max-age=0,must-revalidate", + }, + "@page-data": { + ...defaultHeaders, + "cache-control": "public,max-age=0,must-revalidate", + }, + "@slice-data": { + ...defaultHeaders, + "cache-control": "public,max-age=0,must-revalidate", + }, + "@static-query-result": { + ...defaultHeaders, + "cache-control": "public,max-age=0,must-revalidate", + }, + "@img-webpack-import": { + ...defaultHeaders, + "cache-control": "public,max-age=31536000,immutable", + }, + "@js": { + ...defaultHeaders, + "cache-control": "public,max-age=31536000,immutable", + }, + } + + // `ntl serve` and actual deploy seem to have possible slight differences around header value formatting + // so this just remove spaces around commas to make it easier to compare + function normalizeHeaderValue(value: string | undefined): string | undefined { + if (typeof value === "undefined") { + return value + } + // Remove spaces around commas + return value.replace(/\s*,\s*/gm, `,`) + } + function checkHeaders( + routeAlias: string, + expectedHeaders?: Record + ) { + if (!expectedHeaders) { + expectedHeaders = expectedHeadersByRouteAlias[routeAlias] + } + + if (!expectedHeaders) { + throw new Error(`No expected headers provided for "${routeAlias}`) + } + + cy.wait(routeAlias).then(interception => { + Object.keys(expectedHeaders).forEach(headerKey => { + const headers = interception.response.headers[headerKey] + + const firstHeader: string = Array.isArray(headers) + ? headers[0] + : headers + + expect(normalizeHeaderValue(firstHeader)).to.eq( + normalizeHeaderValue(expectedHeaders[headerKey]) + ) + }) + }) + } + + beforeEach(() => { + cy.intercept("/", WorkaroundCachedResponse).as("index") + cy.intercept("routes/ssr/static", WorkaroundCachedResponse).as("ssr") + cy.intercept("routes/dsg/static", WorkaroundCachedResponse).as("dsg") + + cy.intercept("**/page-data.json", WorkaroundCachedResponse).as("page-data") + cy.intercept("**/app-data.json", WorkaroundCachedResponse).as("app-data") + cy.intercept("**/slice-data/*.json", WorkaroundCachedResponse).as( + "slice-data" + ) + cy.intercept("**/page-data/sq/d/*.json", WorkaroundCachedResponse).as( + "static-query-result" + ) + + cy.intercept("/static/astro-**.png", WorkaroundCachedResponse).as( + "img-webpack-import" + ) + cy.intercept("*.js", WorkaroundCachedResponse).as("js") + }) + + it("should contain correct headers for index page", () => { + cy.visit("/").waitForRouteChange() + + checkHeaders("@index", { + ...defaultHeaders, + "x-custom-header": "my custom header value", + "cache-control": "public,max-age=0,must-revalidate", + }) + + checkHeaders("@app-data") + checkHeaders("@page-data") + checkHeaders("@slice-data") + checkHeaders("@static-query-result") + + // index page is only one showing webpack imported image + checkHeaders("@img-webpack-import") + checkHeaders("@js") + }) + + it("should contain correct headers for ssr page", () => { + cy.visit("routes/ssr/static").waitForRouteChange() + + checkHeaders("@ssr", { + ...defaultHeaders, + "x-custom-header": "my custom header value", + "x-ssr-header": "my custom header value from config", + "x-ssr-header-getserverdata": "my custom header value from getServerData", + "x-ssr-header-overwrite": "getServerData wins", + }) + + checkHeaders("@app-data") + // page-data is baked into SSR page so it's not fetched and we don't assert it + checkHeaders("@slice-data") + checkHeaders("@static-query-result") + checkHeaders("@js") + }) + + it("should contain correct headers for dsg page", () => { + cy.visit("routes/dsg/static").waitForRouteChange() + + checkHeaders("@dsg", { + ...defaultHeaders, + "x-custom-header": "my custom header value", + "x-dsg-header": "my custom header value", + }) + + checkHeaders("@app-data") + checkHeaders("@page-data") + checkHeaders("@slice-data") + checkHeaders("@static-query-result") + checkHeaders("@js") + }) +}) diff --git a/e2e-tests/adapters/cypress/utils/dont-cache-responses-in-browser.ts b/e2e-tests/adapters/cypress/utils/dont-cache-responses-in-browser.ts new file mode 100644 index 0000000000000..7122b390e67d9 --- /dev/null +++ b/e2e-tests/adapters/cypress/utils/dont-cache-responses-in-browser.ts @@ -0,0 +1,21 @@ +import { CyHttpMessages } from "cypress/types/net-stubbing" + +/** + * https://docs.cypress.io/api/commands/intercept#cyintercept-and-request-caching + * + * For responses that are to be cached we need to use a trick so browser doesn't cache them + * So this enforces `no-store` cache-control header before response hits the browser + * and then restore original cache-control value for assertions. + */ +export const WorkaroundCachedResponse = ( + req: CyHttpMessages.IncomingHttpRequest +): void | Promise => { + req.on("before:response", res => { + res.headers["x-original-cache-control"] = res.headers["cache-control"] + res.headers["cache-control"] = "no-store" + }) + req.on("after:response", res => { + res.headers["cache-control"] = res.headers["x-original-cache-control"] + delete res.headers["x-original-cache-control"] + }) +} diff --git a/e2e-tests/adapters/debug-adapter.ts b/e2e-tests/adapters/debug-adapter.ts index 9333d1bff0733..944a9f08d6d25 100644 --- a/e2e-tests/adapters/debug-adapter.ts +++ b/e2e-tests/adapters/debug-adapter.ts @@ -1,7 +1,7 @@ import { inspect } from "util" import type { AdapterInit } from "gatsby" -const createTestingAdapter: AdapterInit = (adapterOptions) => { +const createTestingAdapter: AdapterInit = adapterOptions => { return { name: `gatsby-adapter-debug`, cache: { @@ -10,10 +10,11 @@ const createTestingAdapter: AdapterInit = (adapterOptions) => { }, store({ directories, reporter }) { reporter.info(`[gatsby-adapter-debug] cache.store() ${directories}`) - } + }, }, adapt({ routesManifest, + headerRoutes, functionsManifest, pathPrefix, trailingSlash, @@ -21,17 +22,24 @@ const createTestingAdapter: AdapterInit = (adapterOptions) => { }) { reporter.info(`[gatsby-adapter-debug] adapt()`) - console.log(`[gatsby-adapter-debug] adapt()`, inspect({ - routesManifest, - functionsManifest, - pathPrefix, - trailingSlash, - }, { - depth: Infinity, - colors: true - })) - } + console.log( + `[gatsby-adapter-debug] adapt()`, + inspect( + { + routesManifest, + headerRoutes, + functionsManifest, + pathPrefix, + trailingSlash, + }, + { + depth: Infinity, + colors: true, + } + ) + ) + }, } } -export default createTestingAdapter \ No newline at end of file +export default createTestingAdapter diff --git a/e2e-tests/adapters/gatsby-config.ts b/e2e-tests/adapters/gatsby-config.ts index 8ccc03130cc54..b9a70fcf6c0bb 100644 --- a/e2e-tests/adapters/gatsby-config.ts +++ b/e2e-tests/adapters/gatsby-config.ts @@ -3,7 +3,8 @@ import debugAdapter from "./debug-adapter" import { siteDescription, title } from "./constants" const shouldUseDebugAdapter = process.env.USE_DEBUG_ADAPTER ?? false -const trailingSlash = (process.env.TRAILING_SLASH || `never`) as GatsbyConfig["trailingSlash"] +const trailingSlash = (process.env.TRAILING_SLASH || + `never`) as GatsbyConfig["trailingSlash"] let configOverrides: GatsbyConfig = {} @@ -21,6 +22,39 @@ const config: GatsbyConfig = { }, trailingSlash, plugins: [], + headers: [ + { + source: `/*`, + headers: [ + { + key: "x-custom-header", + value: "my custom header value", + }, + ], + }, + { + source: `routes/ssr/*`, + headers: [ + { + key: "x-ssr-header", + value: "my custom header value from config", + }, + { + key: "x-ssr-header-overwrite", + value: "config wins", + }, + ], + }, + { + source: `routes/dsg/*`, + headers: [ + { + key: "x-dsg-header", + value: "my custom header value", + }, + ], + }, + ], ...configOverrides, } diff --git a/e2e-tests/adapters/scripts/deploy-and-run/netlify.mjs b/e2e-tests/adapters/scripts/deploy-and-run/netlify.mjs index ed09a06299eac..644dba2ee83f4 100644 --- a/e2e-tests/adapters/scripts/deploy-and-run/netlify.mjs +++ b/e2e-tests/adapters/scripts/deploy-and-run/netlify.mjs @@ -37,24 +37,21 @@ console.log(`Deployed to ${deployInfo.deploy_url}`) try { await execa(`npm`, [`run`, npmScriptToRun], { stdio: `inherit` }) } finally { - if (!process.env.GATSBY_TEST_SKIP_CLEANUP) { - console.log(`Deleting project with deploy_id ${deployInfo.deploy_id}`) - - const deleteResponse = await execa("ntl", [ - "api", - "deleteDeploy", - "--data", - `{ "deploy_id": "${deployInfo.deploy_id}" }`, - ]) - - if (deleteResponse.exitCode !== 0) { - throw new Error( - `Failed to delete project ${deleteResponse.stdout} ${deleteResponse.stderr} (${deleteResponse.exitCode})` - ) - } - - console.log( - `Successfully deleted project with deploy_id ${deployInfo.deploy_id}` - ) - } + // if (!process.env.GATSBY_TEST_SKIP_CLEANUP) { + // console.log(`Deleting project with deploy_id ${deployInfo.deploy_id}`) + // const deleteResponse = await execa("ntl", [ + // "api", + // "deleteDeploy", + // "--data", + // `{ "deploy_id": "${deployInfo.deploy_id}" }`, + // ]) + // if (deleteResponse.exitCode !== 0) { + // throw new Error( + // `Failed to delete project ${deleteResponse.stdout} ${deleteResponse.stderr} (${deleteResponse.exitCode})` + // ) + // } + // console.log( + // `Successfully deleted project with deploy_id ${deployInfo.deploy_id}` + // ) + // } } diff --git a/e2e-tests/adapters/src/pages/routes/ssr/static.jsx b/e2e-tests/adapters/src/pages/routes/ssr/static.jsx index 971c95cbd4827..2af8965092c81 100644 --- a/e2e-tests/adapters/src/pages/routes/ssr/static.jsx +++ b/e2e-tests/adapters/src/pages/routes/ssr/static.jsx @@ -7,15 +7,17 @@ const SSR = ({ serverData }) => {

SSR

-
-            {JSON.stringify({ serverData }, null, 2)}
-          
+
{JSON.stringify({ serverData }, null, 2)}
-
{JSON.stringify(serverData?.arg?.query)}
-
{JSON.stringify(serverData?.arg?.params)}
+
+            {JSON.stringify(serverData?.arg?.query)}
+          
+
+            {JSON.stringify(serverData?.arg?.params)}
+          
@@ -32,5 +34,9 @@ export function getServerData(arg) { ssr: true, arg, }, + headers: { + "x-ssr-header-getserverdata": "my custom header value from getServerData", + "x-ssr-header-overwrite": "getServerData wins", + }, } -} \ No newline at end of file +} diff --git a/packages/gatsby-adapter-netlify/src/index.ts b/packages/gatsby-adapter-netlify/src/index.ts index bd4bca28dad52..8c13e36f4e823 100644 --- a/packages/gatsby-adapter-netlify/src/index.ts +++ b/packages/gatsby-adapter-netlify/src/index.ts @@ -62,9 +62,14 @@ const createNetlifyAdapter: AdapterInit = options => { } }, }, - async adapt({ routesManifest, functionsManifest }): Promise { + async adapt({ + routesManifest, + functionsManifest, + headerRoutes, + }): Promise { const { lambdasThatUseCaching } = await handleRoutesManifest( - routesManifest + routesManifest, + headerRoutes ) // functions handling diff --git a/packages/gatsby-adapter-netlify/src/route-handler.ts b/packages/gatsby-adapter-netlify/src/route-handler.ts index a80930c2ca4e6..2faf3216f59f6 100644 --- a/packages/gatsby-adapter-netlify/src/route-handler.ts +++ b/packages/gatsby-adapter-netlify/src/route-handler.ts @@ -1,4 +1,4 @@ -import type { RoutesManifest } from "gatsby" +import type { RoutesManifest, HeaderRoutes } from "gatsby" import { tmpdir } from "os" import { Transform } from "stream" import { join, basename } from "path" @@ -130,7 +130,17 @@ export async function injectEntries( await fs.move(tmpFile, fileName) } -export function processRoutesManifest(routesManifest: RoutesManifest): { +function buildHeaderString(path, headers): string { + return `${encodeURI(path)}\n${headers.reduce((acc, curr) => { + acc += ` ${curr.key}: ${curr.value}\n` + return acc + }, ``)}` +} + +export function processRoutesManifest( + routesManifest: RoutesManifest, + headerRoutes: HeaderRoutes +): { redirects: string headers: string lambdasThatUseCaching: Map @@ -212,25 +222,33 @@ export function processRoutesManifest(routesManifest: RoutesManifest): { )} 200\n` } - _headers += `${encodeURI(fromPath)}\n${route.headers.reduce( - (acc, curr) => { - acc += ` ${curr.key}: ${curr.value}\n` - return acc - }, - `` - )}` + if (!headerRoutes) { + // don't generate _headers from routesManifest if headerRoutes are provided + _headers += buildHeaderString(route.path, route.headers) + } + } + + if (headerRoutes) { + _headers = headerRoutes.reduce((acc, curr) => { + acc += buildHeaderString(curr.path, curr.headers) + return acc + }, ``) } } return { redirects: _redirects, headers: _headers, lambdasThatUseCaching } } export async function handleRoutesManifest( - routesManifest: RoutesManifest + routesManifest: RoutesManifest, + headerRoutes: HeaderRoutes ): Promise<{ lambdasThatUseCaching: Map }> { - const { redirects, headers, lambdasThatUseCaching } = - processRoutesManifest(routesManifest) + const { redirects, headers, lambdasThatUseCaching } = processRoutesManifest( + routesManifest, + headerRoutes + ) + await injectEntries(`public/_redirects`, redirects) await injectEntries(`public/_headers`, headers)