diff --git a/.changeset/unlucky-cats-lie.md b/.changeset/unlucky-cats-lie.md new file mode 100644 index 0000000000..50b9c311ba --- /dev/null +++ b/.changeset/unlucky-cats-lie.md @@ -0,0 +1,35 @@ +--- +'graphql-yoga': minor +--- + +Customize the landing page by passing a custom renderer that returns `Response` to the `landingPage` +option + +```ts +import { createYoga } from 'graphql-yoga' + +const yoga = createYoga({ + landingPage: ({ url, fetchAPI }) => { + return new fetchAPI.Response( + /* HTML */ ` + + + + 404 Not Found + + +

404 Not Found

+

Sorry, the page (${url.pathname}) you are looking for could not be found.

+ + + `, + { + status: 404, + headers: { + 'Content-Type': 'text/html' + } + } + ) + } +}) +``` \ No newline at end of file diff --git a/packages/graphql-yoga/__tests__/404.spec.ts b/packages/graphql-yoga/__tests__/404.spec.ts index 817e928082..69a8371a84 100644 --- a/packages/graphql-yoga/__tests__/404.spec.ts +++ b/packages/graphql-yoga/__tests__/404.spec.ts @@ -17,10 +17,9 @@ describe('404', () => { logging: false, }); const url = `http://localhost:4000/notgraphql`; - const response = await yoga.fetch( - url.replace('mypath', 'yourpath') + '?query=' + encodeURIComponent('{ __typename }'), - { method: 'GET' }, - ); + const response = await yoga.fetch(url + '?query=' + encodeURIComponent('{ __typename }'), { + method: 'GET', + }); expect(response.status).toEqual(404); expect(await response.text()).toEqual(''); @@ -77,4 +76,22 @@ describe('404', () => { const body = await response.text(); expect(body).toEqual('Do you really like em?'); }); + it('supports custom landing page', async () => { + const customLandingPageContent = 'My Custom Landing Page'; + const yoga = createYoga({ + logging: false, + landingPage({ fetchAPI }) { + return new fetchAPI.Response(customLandingPageContent, { + status: 200, + }); + }, + }); + const response = await yoga.fetch(`http://localhost:4000/notgraphql`, { + method: 'GET', + headers: { Accept: 'text/html' }, + }); + expect(response.status).toEqual(200); + const body = await response.text(); + expect(body).toEqual(customLandingPageContent); + }); }); diff --git a/packages/graphql-yoga/src/index.ts b/packages/graphql-yoga/src/index.ts index 3a893bd1f1..5c34b831af 100644 --- a/packages/graphql-yoga/src/index.ts +++ b/packages/graphql-yoga/src/index.ts @@ -39,3 +39,4 @@ export { } from '@envelop/core'; export { getSSEProcessor } from './plugins/result-processor/sse.js'; export { useExecutionCancellation } from './plugins/use-execution-cancellation.js'; +export { LandingPageRenderer, LandingPageRendererOpts } from './plugins/use-unhandled-route.js'; diff --git a/packages/graphql-yoga/src/plugins/use-unhandled-route.ts b/packages/graphql-yoga/src/plugins/use-unhandled-route.ts index 9d4ad44e6e..66c126f9d8 100644 --- a/packages/graphql-yoga/src/plugins/use-unhandled-route.ts +++ b/packages/graphql-yoga/src/plugins/use-unhandled-route.ts @@ -1,9 +1,41 @@ +import { PromiseOrValue } from '@envelop/core'; +import { isPromise } from '@graphql-tools/utils'; import landingPageBody from '../landing-page-html.js'; import { FetchAPI } from '../types.js'; import type { Plugin } from './types.js'; +export interface LandingPageRendererOpts { + request: Request; + fetchAPI: FetchAPI; + url: URL; + graphqlEndpoint: string; + // Not sure why the global `URLPattern` causes errors with the ponyfill typings + // So instead we use this which points to the same type + urlPattern: InstanceType; +} + +export type LandingPageRenderer = (opts: LandingPageRendererOpts) => PromiseOrValue; + +export const defaultRenderLandingPage: LandingPageRenderer = function defaultRenderLandingPage( + opts: LandingPageRendererOpts, +) { + return new opts.fetchAPI.Response( + landingPageBody + .replace(/__GRAPHIQL_LINK__/g, opts.graphqlEndpoint) + .replace(/__REQUEST_PATH__/g, opts.url.pathname), + { + status: 200, + statusText: 'OK', + headers: { + 'Content-Type': 'text/html', + }, + }, + ); +}; + export function useUnhandledRoute(args: { graphqlEndpoint: string; + landingPageRenderer?: LandingPageRenderer; showLandingPage: boolean; }): Plugin { let urlPattern: URLPattern; @@ -13,8 +45,10 @@ export function useUnhandledRoute(args: { }); return urlPattern; } + const landingPageRenderer: LandingPageRenderer = + args.landingPageRenderer || defaultRenderLandingPage; return { - onRequest({ request, fetchAPI, endResponse, url }) { + onRequest({ request, fetchAPI, endResponse, url }): PromiseOrValue { if ( !request.url.endsWith(args.graphqlEndpoint) && !request.url.endsWith(`${args.graphqlEndpoint}/`) && @@ -27,20 +61,19 @@ export function useUnhandledRoute(args: { request.method === 'GET' && !!request.headers?.get('accept')?.includes('text/html') ) { - endResponse( - new fetchAPI.Response( - landingPageBody - .replace(/__GRAPHIQL_LINK__/g, args.graphqlEndpoint) - .replace(/__REQUEST_PATH__/g, url.pathname), - { - status: 200, - statusText: 'OK', - headers: { - 'Content-Type': 'text/html', - }, - }, - ), - ); + const landingPage$ = landingPageRenderer({ + request, + fetchAPI, + url, + graphqlEndpoint: args.graphqlEndpoint, + get urlPattern() { + return getUrlPattern(fetchAPI); + }, + }); + if (isPromise(landingPage$)) { + return landingPage$.then(endResponse); + } + endResponse(landingPage$); return; } diff --git a/packages/graphql-yoga/src/server.ts b/packages/graphql-yoga/src/server.ts index eadd1eaf5b..3805a26dfe 100644 --- a/packages/graphql-yoga/src/server.ts +++ b/packages/graphql-yoga/src/server.ts @@ -59,7 +59,7 @@ import { import { useRequestParser } from './plugins/use-request-parser.js'; import { useResultProcessors } from './plugins/use-result-processor.js'; import { useSchema, YogaSchemaDefinition } from './plugins/use-schema.js'; -import { useUnhandledRoute } from './plugins/use-unhandled-route.js'; +import { LandingPageRenderer, useUnhandledRoute } from './plugins/use-unhandled-route.js'; import { processRequest as processGraphQLParams, processResult } from './process-request.js'; import { FetchAPI, @@ -120,7 +120,7 @@ export type YogaServerOptions = { /** * Whether the landing page should be shown. */ - landingPage?: boolean | undefined; + landingPage?: boolean | LandingPageRenderer | undefined; /** * GraphiQL options @@ -360,11 +360,14 @@ export class YogaServer< addPlugin(useLimitBatching(batchingLimit)); // @ts-expect-error Add plugins has context but this hook doesn't care addPlugin(useCheckGraphQLQueryParams()); + const showLandingPage = !!(options?.landingPage ?? true); addPlugin( // @ts-expect-error Add plugins has context but this hook doesn't care useUnhandledRoute({ graphqlEndpoint, - showLandingPage: options?.landingPage ?? true, + showLandingPage, + landingPageRenderer: + typeof options?.landingPage === 'function' ? options.landingPage : undefined, }), ); // We check the method after user-land plugins because the plugin might support more methods (like graphql-sse). diff --git a/packages/nestjs/__tests__/graphql-http.spec.ts b/packages/nestjs/__tests__/graphql-http.spec.ts index e3651b2324..964e945336 100644 --- a/packages/nestjs/__tests__/graphql-http.spec.ts +++ b/packages/nestjs/__tests__/graphql-http.spec.ts @@ -30,7 +30,6 @@ describe('GraphQL over HTTP', () => { })) { if ( // we dont control the JSON parsing - audit.id === 'D477' || audit.id === 'A5BF' ) { it.todo(audit.name); diff --git a/website/src/pages/docs/features/_meta.ts b/website/src/pages/docs/features/_meta.ts index d457841485..f2f7fbea3c 100644 --- a/website/src/pages/docs/features/_meta.ts +++ b/website/src/pages/docs/features/_meta.ts @@ -23,4 +23,5 @@ export default { 'envelop-plugins': 'Plugins', testing: 'Testing', jwt: 'JWT', + 'landing-page': 'Landing Page', }; diff --git a/website/src/pages/docs/features/landing-page.mdx b/website/src/pages/docs/features/landing-page.mdx new file mode 100644 index 0000000000..2057ed6376 --- /dev/null +++ b/website/src/pages/docs/features/landing-page.mdx @@ -0,0 +1,67 @@ +--- +description: Learn more about landing page customization in GraphQL Yoga +--- + +# Landing Page + +When GraphQL Yoga hits 404, it returns a default landing page like below; + +![image](https://github.com/dotansimha/graphql-yoga/assets/20847995/7bc058db-1823-4316-86ff-cf1847522da5) + +If you don't expect to hit 404 in that path, you can configure `graphqlEndpoint` to avoid this page. + +```ts +import { createYoga } from 'graphql-yoga' + +const yoga = createYoga({ + graphqlEndpoint: '/my-graphql-endpoint' +}) +``` + +How ever you can disable the landing page, and just get 404 error directly by setting `landingPage` +to `false`. + +```ts +import { createYoga } from 'graphql-yoga' + +const yoga = createYoga({ + landingPage: false +}) +``` + +You can also customize the landing page by passing a custom renderer that returns `Response` to the +`landingPage` option. + +```ts +import { createYoga } from 'graphql-yoga' + +const yoga = createYoga({ + landingPage: ({ url, fetchAPI }) => { + return new fetchAPI.Response( + /* HTML */ ` + + + + 404 Not Found + + +

404 Not Found

+

Sorry, the page (${url.pathname}) you are looking for could not be found.

+ + + `, + { + status: 404, + headers: { + 'Content-Type': 'text/html' + } + } + ) + } +}) +``` + + + `Response` is part of the Fetch API, so if you want to learn more about it, you can check the [MDN + documentation](https://developer.mozilla.org/en-US/docs/Web/API/Response). +