diff --git a/apollo/apollo-server.ts b/apollo/apollo-server.ts new file mode 100644 index 000000000..de854f219 --- /dev/null +++ b/apollo/apollo-server.ts @@ -0,0 +1,102 @@ +import type { ServerResponse } from 'http' +import type { LandingPage } from 'apollo-server-plugin-base' +import { + useBody, + useQuery, + IncomingMessage, + CompatibilityEvent, + EventHandler, +} from 'h3' +import type { GraphQLOptions } from 'apollo-server-core' +import { + ApolloServerBase, + convertNodeHttpToRequest, + runHttpQuery, + isHttpQueryError, +} from 'apollo-server-core' + +export interface ServerRegistration { + path?: string + disableHealthCheck?: boolean + onHealthCheck?: (event: CompatibilityEvent) => Promise +} + +// Originally taken from https://github.com/newbeea/nuxt3-apollo-starter/blob/master/server/graphql/apollo-server.ts +// TODO: Implement health check https://github.com/apollographql/apollo-server/blob/main/docs/source/monitoring/health-checks.md +export class ApolloServer extends ApolloServerBase { + async createGraphQLServerOptions( + request?: IncomingMessage, + reply?: ServerResponse + ): Promise { + return this.graphQLServerOptions({ request, reply }) + } + + createHandler({ + path, + disableHealthCheck, + onHealthCheck, + }: ServerRegistration = {}): EventHandler { + this.graphqlPath = path || '/graphql' + const landingPage = this.getLandingPage() + + return async (event: CompatibilityEvent) => { + const options = await this.createGraphQLServerOptions( + event.req, + event.res + ) + try { + if (landingPage) { + const landingPageHtml = this.handleLandingPage(event, landingPage) + if (landingPageHtml) { + return landingPageHtml + } + } + + const { graphqlResponse, responseInit } = await runHttpQuery([], { + method: event.req.method || 'GET', + options, + query: + event.req.method === 'POST' + ? await useBody(event) + : useQuery(event), + request: convertNodeHttpToRequest(event.req), + }) + if (responseInit.headers) { + for (const [name, value] of Object.entries( + responseInit.headers + )) + event.res.setHeader(name, value) + } + event.res.statusCode = responseInit.status || 200 + return graphqlResponse + } catch (error: any) { + if (!isHttpQueryError(error)) { + throw error + } + + if (error.headers) { + for (const [name, value] of Object.entries(error.headers)) + event.res.setHeader(name, value) + } + event.res.statusCode = error.statusCode || 500 + return error.message + } + } + } + + private handleLandingPage( + event: CompatibilityEvent, + landingPage: LandingPage + ): string | undefined { + const url = event.req.url?.split('?')[0] + if (event.req.method === 'GET' && url === this.graphqlPath) { + const prefersHtml = event.req.headers.accept?.includes('text/html') + + if (prefersHtml) { + return landingPage.html + } + } + } +} + +export default ApolloServer diff --git a/server/index.ts b/server/index.ts index d739a7774..6057fafd5 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,17 +1,16 @@ import http from 'http' -import express from 'express' -import { ApolloServer } from 'apollo-server-express' import 'reflect-metadata' // Needed for tsyringe import { ApolloServerPluginDrainHttpServer, ApolloServerPluginLandingPageLocalDefault, } from 'apollo-server-core' import { InMemoryLRUCache } from '@apollo/utils.keyvaluecache' -import { Environment } from '../config' +import { createApp } from 'h3' import { configure as configureTsyringe } from './tsyringe.config' import { buildContext } from './context' import { loadSchemaWithResolvers } from './schema' import { resolve } from './tsyringe' +import { ApolloServer } from '~/apollo/apollo-server' // Workaround for issue with Azure deploy: https://github.com/unjs/nitro/issues/351 // Original code taken from https://github.com/nodejs/node/blob/main/lib/_http_outgoing.js @@ -44,46 +43,78 @@ http.OutgoingMessage.prototype.setHeader = function setHeader(name, value) { return this } -// Create express instance -const app = express() -if (useRuntimeConfig().public.environment === Environment.Production) { - // Azure uses a reverse proxy, which changes some API values (notably express things it is not accessed through a secure https connection) - // So we need to adjust for this, see http://expressjs.com/en/guide/behind-proxies.html - app.set('trust proxy', 1) +// Workaround for issue with Azure deploy: https://github.com/unjs/nitro/issues/351 +// Original code taken from https://github.com/nodejs/node/blob/main/lib/internal/streams/readable.js +http.IncomingMessage.Readable.prototype.unpipe = function (dest) { + // CHANGED: Add fallback if not existing + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + // @ts-ignore: is workaround anyway + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const state = (this._readableState as any) || { pipes: [] } + const unpipeInfo = { hasUnpiped: false } + + // If we're not piping anywhere, then do nothing. + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (state.pipes.length === 0) return this + + if (!dest) { + // remove all. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const dests = state.pipes as any[] + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + state.pipes = [] + this.pause() + + for (let i = 0; i < dests.length; i++) + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + dests[i].emit('unpipe', this, { hasUnpiped: false }) + return this + } + + // Try to find the right one. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + const index = state.pipes.indexOf(dest) + if (index === -1) return this + + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + state.pipes.splice(index, 1) + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (state.pipes.length === 0) this.pause() + + dest.emit('unpipe', this, unpipeInfo) + + return this } + +const app = createApp() +// eslint-disable-next-line @typescript-eslint/no-misused-promises const httpServer = http.createServer(app) -// TODO: Replace this with await, once esbuild supports top-level await -void configureTsyringe() - .then(async () => { - const passportInitializer = resolve('PassportInitializer') - passportInitializer.initialize() - passportInitializer.install(app) - - const server = new ApolloServer({ - schema: await loadSchemaWithResolvers(), - context: buildContext, - introspection: true, - plugins: [ - // Enable Apollo Studio in development, and also in production (at least for now) - ApolloServerPluginLandingPageLocalDefault({ footer: false }), - // Gracefully shutdown HTTP server when Apollo server terminates - ApolloServerPluginDrainHttpServer({ httpServer }), - ], - // Only reply to requests with a Content-Type header to prevent CSRF and XS-Search attacks - // https://www.apollographql.com/docs/apollo-server/security/cors/#preventing-cross-site-request-forgery-csrf - csrfPrevention: true, - cache: new InMemoryLRUCache(), - }) - - async function startServer() { - await server.start() - server.applyMiddleware({ app, path: '/' }) - } - void startServer() - }) - .catch((error) => { - console.error('Error while executing configureTsyringe', error) +export default defineLazyEventHandler(async () => { + await configureTsyringe() + + const passportInitializer = resolve('PassportInitializer') + passportInitializer.initialize() + passportInitializer.install(app) + + const server = new ApolloServer({ + schema: await loadSchemaWithResolvers(), + context: buildContext, + introspection: true, + plugins: [ + // Enable Apollo Studio in development, and also in production (at least for now) + ApolloServerPluginLandingPageLocalDefault({ footer: false }), + // Gracefully shutdown HTTP server when Apollo server terminates + ApolloServerPluginDrainHttpServer({ httpServer }), + ], + // Only reply to requests with a Content-Type header to prevent CSRF and XS-Search attacks + // https://www.apollographql.com/docs/apollo-server/security/cors/#preventing-cross-site-request-forgery-csrf + csrfPrevention: true, + cache: new InMemoryLRUCache(), }) -export default app + await server.start() + return server.createHandler({ + path: '/api', + }) +}) diff --git a/server/user/passport-initializer.ts b/server/user/passport-initializer.ts index 3e3f9c013..5e535db31 100644 --- a/server/user/passport-initializer.ts +++ b/server/user/passport-initializer.ts @@ -1,5 +1,5 @@ import connectRedis from 'connect-redis' -import { Express } from 'express-serve-static-core' +import { App } from 'h3' import session from 'express-session' import passport from 'passport' import { RedisClientType } from 'redis' @@ -25,7 +25,7 @@ export default class PassportInitializer { ) } - install(app: Express): void { + install(app: App): void { const config = useRuntimeConfig() // TODO: Use redis store also for development as soon as https://github.com/tj/connect-redis/issues/336 is fixed (and mock-redis is compatible with redis v4) @@ -43,6 +43,7 @@ export default class PassportInitializer { // Add middleware that sends and receives the session ID using cookies // See https://github.com/expressjs/session#readme app.use( + // @ts-ignore: https://github.com/unjs/h3/issues/146 session({ store, // The secret used to sign the session cookie @@ -64,6 +65,7 @@ export default class PassportInitializer { }) ) // Add passport as middleware (this more or less only adds the _passport variable to the request) + // @ts-ignore: https://github.com/unjs/h3/issues/146 app.use(passport.initialize()) // Add middleware that authenticates request based on the current session state (i.e. we alter the request to contain the hydrated user object instead of only the session ID) app.use(passport.session())