Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Apollo Server Middleware #434

Open
yusukebe opened this issue Jul 30, 2022 · 18 comments
Open

Apollo Server Middleware #434

yusukebe opened this issue Jul 30, 2022 · 18 comments
Labels
enhancement New feature or request. middleware

Comments

@yusukebe
Copy link
Member

How about building Apollo Server Middleware as third-party middleware?

GraphQL middleware is available, but Apollo Sever middleware would be nice to have. Repository and npm repository name will be:

  • github.com/honojs/apollo-server
  • @honojs/apollo-server

We can use this issue apollographql/apollo-server#6034 (comment) as a reference.

@yusukebe yusukebe added enhancement New feature or request. issue:middleware labels Jul 30, 2022
@metrue
Copy link
Contributor

metrue commented Jul 30, 2022

I tried months ago in Hono, but it failed with some dependencies in edge environment (but I already forgot what exactly it's now), Let me check it again to have a try.

@yusukebe
Copy link
Member Author

Ah, I see. Maybe it's not so straightforward.

@ronny
Copy link

ronny commented Nov 12, 2022

I think basically the blocker is in @apollo/server itself, it assumes a Node.js runtime in many places by importing things like os, util, zlib, and so on.

apollographql/apollo-server#6034 (comment)

@metrue
Copy link
Contributor

metrue commented Nov 13, 2022

Thanks @ronny ,yeah, I had been doing the the same experiment, and saw the same thing. There're not only node runtime anymore, there're bunch of services running on bun, deno, or workerd , the @apollo/server should make some changes to support those runtimes.

@dimik
Copy link
Contributor

dimik commented May 27, 2023

I tested edge runtime apollo integration for cloudflare workers recently. but TTFB is too long as it has to start server for a single request and shut it down after it reply. So not make much sense IMO

@rafaell-lycan
Copy link

I believe a proper wrapper for both Apollo and/or GraphQL Yoga would be a nice touch.

@FaureAlexis
Copy link

Hey @yusukebe ! Can I do it ?

@yusukebe
Copy link
Member Author

Hi @FaureAlexis

Thanks. But perhaps we can run Apollo Server with app.mount() on the Hono app. Though this issue is open, since we would like to have as little middleware managed in the @hono namespace as possible, it may not be necessary to create @hono/apollo-server.

Of course, you can create it in your personal repository.

@obedm503
Copy link

obedm503 commented Mar 28, 2024

I'm using hono with apollo server. @FaureAlexis this may serve as a starting point.

import { StatusCode } from 'hono/utils/http-status';

const apollo = new ApolloServer();
await apollo.start();
app.on(['GET', 'POST', 'OPTIONS'], '/graphql', async ctx => {
  if (ctx.req.method === 'OPTIONS') {
    // prefer status 200 over 204
    // https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/OPTIONS#status_code
    return ctx.text('');
  }

  const httpGraphQLResponse = await apollo.executeHTTPGraphQLRequest({
    httpGraphQLRequest: {
      body: ctx.req.method === 'POST' ? await ctx.req.json() : undefined,
      headers: new HeaderMap(Object.entries(ctx.req.header())),
      method: ctx.req.method,
      search: new URL(ctx.req.url).search,
    },
    context: async () => getContext(ctx), // getContext is my own graphql context factory
  });

  const { headers, body, status } = httpGraphQLResponse;

  for (const [headerKey, headerValue] of headers) {
    ctx.header(headerKey, headerValue);
  }

  ctx.status((status as StatusCode) ?? 200);

  if (body.kind === 'complete') {
    return ctx.body(body.string);
  }

  return ctx.body(body.asyncIterator);
});

@yusukebe What is the benefit of app.mount() over app.on()?

@yusukebe
Copy link
Member Author

@obedm503 That's great!!

@yusukebe What is the benefit of app.mount() over app.on()?

I did not try but if we use integration like cloudflare integration, we can write just like this:

app.mount('/grahph', handleGraphQLRequest)

But your way is better than it since we don't have to use integrations and app.mount() can't manage each handler of the adapted app.

@obedm503
Copy link

Sounds good.

I still have a few questions about that might help @FaureAlexis and anyone else trying to do this.

How does hono handle duplicate headers? I am naively converting the object returned by ctx.req.header() into apollo's HeaderMap but this might not work with duplicates. Node's headers can be either a single string of a list of strings. Apollo integrations (cloudflare, koa) usually handle this by doing something like

const headerMap = new HeaderMap();
headers.forEach((value, key) => {
  headerMap.set(key, Array.isArray(value) ? value.join(', ') : value);
});

but this would not be needed if hono already normalizes them.

Also, is ctx.body() the right way to return a streamed response? Does it need to be converted to a Readable stream beforehand? Should stream.pipe() from hono/streaming be used instead? httpGraphQLResponse.body.asyncIterator is of type AsyncIterableIterator<string>

@yusukebe
Copy link
Member Author

yusukebe commented Mar 28, 2024

@obedm503

How does hono handle duplicate headers?

It uses the Header object to manage header values. The append method allows it to handle multiple values, so we don't do anything special for duplicated headers.

Also, is ctx.body() the right way to return a streamed response?

Yes! But is stream ReadableStream? You can return a RadableStream content with c.body():

return c.body(stream)

If not, you may have to use hono/streaming.

@metrue
Copy link
Contributor

metrue commented Mar 28, 2024

I tested edge runtime apollo integration for cloudflare workers recently. but TTFB is too long as it has to start server for a single request and shut it down after it reply. So not make much sense IMO

Yeah, that's why I started the project https://github.com/metrue/EdgeQL aiming to provide a fast way to have GraphQL on edge.

@FaureAlexis
Copy link

Thanks you very much @obedm503 ! Works like a charm

@FaureAlexis
Copy link

For headers, I was doing like this :

const headers = new HeaderMap();
 c.req.raw.headers.forEach((value: string, key: string) => {
    if (value) {
        headers.set(key, Array.isArray(value) ? value.join(', ') : value);
    }
  })

@obedm503
Copy link

@FaureAlexis Since the Headers class already handles duplicates, it's not necessary to join header values manually. As per MDN. I tested it to confirm.

When Header values are iterated over, they are automatically sorted in lexicographical order, and values from duplicate header names are combined.


Here's my current implementation of a honoApollo middleware factory function. Feel free to use it and even create an @honojs/apollo-server or @as-integrations/hono package.

The honoApollo would be used as such:

import { honoApollo } from './hono-apollo';

const app = new Hono();
const apolloServer = new ApolloServer();
await apolloServer.start();
app.route(
  '/graphql',
  honoApollo(apolloServer, async ctx => getContext(ctx)),
);

hono-apollo.ts would look like this:

import {
  HeaderMap,
  type ApolloServer,
  type BaseContext,
  type ContextFunction,
  type HTTPGraphQLRequest,
} from '@apollo/server';
import type { Context as HonoContext } from 'hono';
import { stream } from 'hono/streaming';
import { Hono } from 'hono/tiny';
import type { BlankSchema, Env } from 'hono/types';
import type { StatusCode } from 'hono/utils/http-status';

export function honoApollo(
  server: ApolloServer<BaseContext>,
  getContext?: ContextFunction<[HonoContext], BaseContext>,
): Hono<Env, BlankSchema, '/'>;
export function honoApollo<TContext extends BaseContext>(
  server: ApolloServer<TContext>,
  getContext: ContextFunction<[HonoContext], TContext>,
): Hono<Env, BlankSchema, '/'>;
export function honoApollo<TContext extends BaseContext>(
  server: ApolloServer<TContext>,
  getContext?: ContextFunction<[HonoContext], TContext>,
) {
  const app = new Hono();

  // Handle `OPTIONS` request
  // Prefer status 200 over 204
  // https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/OPTIONS#status_code
  app.options('/', ctx => ctx.text(''));

  // This `any` is safe because the overload above shows that context can
  // only be left out if you're using BaseContext as your context, and {} is a
  // valid BaseContext.
  const defaultContext: ContextFunction<[HonoContext], any> = async () => ({});
  const context = getContext ?? defaultContext;

  app.on(['GET', 'POST'], '/', async ctx => {
    const headerMap = new HeaderMap();
    // Use `ctx.req.raw.headers` to avoid multiple loops and intermediate objects
    ctx.req.raw.headers.forEach((value, key) => {
      // When Header values are iterated over, they are automatically sorted in
      // lexicographical order, and values from duplicate header names are combined.
      // https://developer.mozilla.org/en-US/docs/Web/API/Headers
      headerMap.set(key, value);
    });
    const httpGraphQLRequest: HTTPGraphQLRequest = {
      // Avoid parsing the body unless necessary
      body: ctx.req.method === 'POST' ? await ctx.req.json() : undefined,
      headers: headerMap,
      method: ctx.req.method,
      search: new URL(ctx.req.url).search,
    };

    const httpGraphQLResponse = await server.executeHTTPGraphQLRequest({
      httpGraphQLRequest,
      context: () => context(ctx),
    });

    for (const [key, value] of httpGraphQLResponse.headers) {
      ctx.header(key, value);
    }

    ctx.status((httpGraphQLResponse.status as StatusCode) ?? 200);

    if (httpGraphQLResponse.body.kind === 'complete') {
      return ctx.body(httpGraphQLResponse.body.string);
    }

    // This should work but remains untested
    const asyncIterator = httpGraphQLResponse.body.asyncIterator;
    return stream(ctx, async stream => {
      for await (const part of asyncIterator) {
        await stream.write(part);
      }
    });
  });

  return app;
}

@yusukebe Do you have any recommendations on the best router to use here? Does it matter?

@yusukebe
Copy link
Member Author

yusukebe commented Apr 9, 2024

@obedm503

Sorry for the super delayed response.

@yusukebe Do you have any recommendations on the best router to use here? Does it matter?

I don't think it matters that much which router you choose. In this case, hono is fine, not hono/tiny in particular. Bundle size will change, but not by that much.

@intellix
Copy link

did anyone get this into production and have any performance statistics? I'd love to replace express with something a little more lightweight if it's possible

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request. middleware
Projects
None yet
Development

No branches or pull requests

8 participants