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).
+