Skip to content

Commit

Permalink
feat: customize landing page via landingPage (#3332)
Browse files Browse the repository at this point in the history
* feat: customize landing page via `landingPage`

* Fix TS

* Comment

* Docs

* Add example to the changeset

* Fix image link
  • Loading branch information
ardatan authored Jun 28, 2024
1 parent fb0e537 commit 0208024
Show file tree
Hide file tree
Showing 8 changed files with 179 additions and 23 deletions.
35 changes: 35 additions & 0 deletions .changeset/unlucky-cats-lie.md
Original file line number Diff line number Diff line change
@@ -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 */ `
<!doctype html>
<html>
<head>
<title>404 Not Found</title>
</head>
<body>
<h1>404 Not Found</h1>
<p>Sorry, the page (${url.pathname}) you are looking for could not be found.</p>
</body>
</html>
`,
{
status: 404,
headers: {
'Content-Type': 'text/html'
}
}
)
}
})
```
25 changes: 21 additions & 4 deletions packages/graphql-yoga/__tests__/404.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
Expand Down Expand Up @@ -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);
});
});
1 change: 1 addition & 0 deletions packages/graphql-yoga/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
63 changes: 48 additions & 15 deletions packages/graphql-yoga/src/plugins/use-unhandled-route.ts
Original file line number Diff line number Diff line change
@@ -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<FetchAPI['URLPattern']>;
}

export type LandingPageRenderer = (opts: LandingPageRendererOpts) => PromiseOrValue<Response>;

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;
Expand All @@ -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<void> {
if (
!request.url.endsWith(args.graphqlEndpoint) &&
!request.url.endsWith(`${args.graphqlEndpoint}/`) &&
Expand All @@ -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;
}

Expand Down
9 changes: 6 additions & 3 deletions packages/graphql-yoga/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -120,7 +120,7 @@ export type YogaServerOptions<TServerContext, TUserContext> = {
/**
* Whether the landing page should be shown.
*/
landingPage?: boolean | undefined;
landingPage?: boolean | LandingPageRenderer | undefined;

/**
* GraphiQL options
Expand Down Expand Up @@ -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).
Expand Down
1 change: 0 additions & 1 deletion packages/nestjs/__tests__/graphql-http.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions website/src/pages/docs/features/_meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ export default {
'envelop-plugins': 'Plugins',
testing: 'Testing',
jwt: 'JWT',
'landing-page': 'Landing Page',
};
67 changes: 67 additions & 0 deletions website/src/pages/docs/features/landing-page.mdx
Original file line number Diff line number Diff line change
@@ -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 */ `
<!doctype html>
<html>
<head>
<title>404 Not Found</title>
</head>
<body>
<h1>404 Not Found</h1>
<p>Sorry, the page (${url.pathname}) you are looking for could not be found.</p>
</body>
</html>
`,
{
status: 404,
headers: {
'Content-Type': 'text/html'
}
}
)
}
})
```

<Callout>
`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).
</Callout>

0 comments on commit 0208024

Please sign in to comment.