From c8b6e88a06793b9c4dfd54aaa21b36a1b421d812 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Tue, 25 Jul 2023 09:12:08 +0200 Subject: [PATCH] use persisted operations for app (#2649) --- .gitignore | 1 + codegen.cjs => codegen.mts | 10 ++++-- .../add-typename-document-transform.mts | 36 +++++++++++++++++++ deployment/services/app.ts | 5 +++ deployment/services/graphql.ts | 2 ++ package.json | 2 +- packages/services/server/README.md | 1 + packages/services/server/package.json | 3 +- .../scripts/copy-persisted-operations.mts | 34 ++++++++++++++++++ packages/services/server/src/environment.ts | 4 +++ .../services/server/src/graphql-handler.ts | 14 +++++++- packages/services/server/src/index.ts | 11 +++++- packages/web/app/README.md | 1 + packages/web/app/environment.ts | 6 ++++ packages/web/app/package.json | 1 + packages/web/app/src/config/frontend-env.ts | 3 ++ packages/web/app/src/lib/urql.ts | 18 ++++++++-- pnpm-lock.yaml | 28 +++++++++++++++ tsconfig.eslint.json | 4 ++- 19 files changed, 174 insertions(+), 10 deletions(-) rename codegen.cjs => codegen.mts (96%) create mode 100644 configs/graphql-code-generator/add-typename-document-transform.mts create mode 100644 packages/services/server/scripts/copy-persisted-operations.mts diff --git a/.gitignore b/.gitignore index 41c93a150..cc5e6eddb 100644 --- a/.gitignore +++ b/.gitignore @@ -129,6 +129,7 @@ cypress packages/web/app/next.config.mjs packages/web/app/environment-*.mjs packages/web/app/src/gql/*.ts +packages/web/app/src/gql/*.json packages/web/app/src/graphql/*.ts _redirects diff --git a/codegen.cjs b/codegen.mts similarity index 96% rename from codegen.cjs rename to codegen.mts index 31a4f1817..59fe38a48 100644 --- a/codegen.cjs +++ b/codegen.mts @@ -1,7 +1,7 @@ -// @ts-check +import { type CodegenConfig } from '@graphql-codegen/cli'; +import { addTypenameDocumentTransform } from './configs/graphql-code-generator/add-typename-document-transform.mjs'; -/** @type {import('@graphql-codegen/cli').CodegenConfig} */ -const config = { +const config: CodegenConfig = { schema: './packages/services/api/src/modules/*/module.graphql.ts', emitLegacyCommonJSImports: true, generates: { @@ -157,7 +157,11 @@ const config = { JSONSchemaObject: 'json-schema-typed#JSONSchema', }, }, + presetConfig: { + persistedDocuments: true, + }, plugins: [], + documentTransforms: [addTypenameDocumentTransform], }, // CLI './packages/libraries/cli/src/gql/': { diff --git a/configs/graphql-code-generator/add-typename-document-transform.mts b/configs/graphql-code-generator/add-typename-document-transform.mts new file mode 100644 index 000000000..9e56753aa --- /dev/null +++ b/configs/graphql-code-generator/add-typename-document-transform.mts @@ -0,0 +1,36 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { Kind, visit } from 'graphql'; +import { Types } from '@graphql-codegen/plugin-helpers'; + +export const addTypenameDocumentTransform: Types.DocumentTransformObject = { + transform({ documents }) { + return documents.map(document => ({ + ...document, + document: document.document + ? visit(document.document, { + SelectionSet(node) { + if ( + !node.selections.find( + selection => selection.kind === 'Field' && selection.name.value === '__typename', + ) + ) { + return { + ...node, + selections: [ + { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: '__typename', + }, + }, + ...node.selections, + ], + }; + } + }, + }) + : undefined, + })); + }, +}; diff --git a/deployment/services/app.ts b/deployment/services/app.ts index e26ab8d6a..84b4ccadf 100644 --- a/deployment/services/app.ts +++ b/deployment/services/app.ts @@ -120,6 +120,11 @@ export function deployApp({ value: 'https://the-guild.dev/graphql/hive/docs', }, + { + name: 'GRAPHQL_PERSISTED_OPERATIONS', + value: '1', + }, + // // AUTH // diff --git a/deployment/services/graphql.ts b/deployment/services/graphql.ts index 05a3a3ffb..f9691c114 100644 --- a/deployment/services/graphql.ts +++ b/deployment/services/graphql.ts @@ -164,6 +164,8 @@ export function deployGraphQL({ AUTH_LEGACY_AUTH0: '1', AUTH_LEGACY_AUTH0_INTERNAL_API_KEY: auth0Config.internalApiKey, AUTH_ORGANIZATION_OIDC: '1', + // Various + GRAPHQL_PERSISTED_OPERATIONS_PATH: './persisted-operations.json', }, exposesMetrics: true, port: 4000, diff --git a/package.json b/package.json index 54f43f7ec..197be35de 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "docker:build": "docker buildx bake -f docker/docker.hcl --load build", "env:sync": "tsx scripts/sync-env-files.ts", "generate": "pnpm --filter @hive/storage db:generate && pnpm graphql:generate", - "graphql:generate": "graphql-codegen --config codegen.cjs", + "graphql:generate": "graphql-codegen --config codegen.mts", "integration:prepare": "cd integration-tests && ./local.sh", "lint": "eslint --cache --ignore-path .gitignore \"{packages,cypress}/**/*.{ts,tsx}\"", "lint:env-template": "tsx scripts/check-env-template.ts", diff --git a/packages/services/server/README.md b/packages/services/server/README.md index 75319f13d..d04c0d683 100644 --- a/packages/services/server/README.md +++ b/packages/services/server/README.md @@ -48,6 +48,7 @@ The GraphQL API for GraphQL Hive. | `PROMETHEUS_METRICS` | No | Whether Prometheus metrics should be enabled | `1` (enabled) or `0` (disabled) | | `PROMETHEUS_METRICS_LABEL_INSTANCE` | No | The instance label added for the prometheus metrics. | `server` | | `REQUEST_LOGGING` | No | Log http requests | `1` (enabled) or `0` (disabled) | +| `GRAPHQL_PERSISTED_OPERATIONS_PATH` | No | The path to a file of persisted operations to | `./persisted-operations.json` | ## Hive Hosted Configuration diff --git a/packages/services/server/package.json b/packages/services/server/package.json index 17c802443..118a900b9 100644 --- a/packages/services/server/package.json +++ b/packages/services/server/package.json @@ -5,7 +5,7 @@ "license": "MIT", "private": true, "scripts": { - "build": "tsx ../../../scripts/runify.ts", + "build": "tsx ../../../scripts/runify.ts && tsx ./scripts/copy-persisted-operations.mts", "dev": "tsup-node --config ../../../configs/tsup/dev.config.node.ts src/dev.ts", "typecheck": "tsc --noEmit" }, @@ -19,6 +19,7 @@ "@escape.tech/graphql-armor-max-depth": "2.1.0", "@escape.tech/graphql-armor-max-directives": "2.0.0", "@escape.tech/graphql-armor-max-tokens": "2.1.0", + "@graphql-yoga/plugin-persisted-operations": "2.0.3", "@graphql-yoga/plugin-response-cache": "2.1.0", "@sentry/integrations": "7.59.3", "@sentry/node": "7.59.3", diff --git a/packages/services/server/scripts/copy-persisted-operations.mts b/packages/services/server/scripts/copy-persisted-operations.mts new file mode 100644 index 000000000..dfd3f92be --- /dev/null +++ b/packages/services/server/scripts/copy-persisted-operations.mts @@ -0,0 +1,34 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as url from 'url'; +import graphql, { Kind, parse, visit } from 'graphql'; + +const dirname = url.fileURLToPath(new URL('.', import.meta.url)); + +const persistedOperationsAppPath = path.join( + dirname, + '..', + '..', + '..', + 'web', + 'app', + 'src', + 'gql', + 'persisted-documents.json', +); +const persistedOperationsDistPath = path.join(dirname, '..', 'dist', 'persisted-operations.json'); + +const persistedOperations: Record = JSON.parse( + await fs.readFile(persistedOperationsAppPath, 'utf-8'), +); + +await fs.writeFile( + persistedOperationsDistPath, + JSON.stringify( + Object.fromEntries( + Object.entries(persistedOperations).map(([hash, document]) => [hash, parse(document)]), + ), + null, + 2, + ), +); diff --git a/packages/services/server/src/environment.ts b/packages/services/server/src/environment.ts index cb5789468..fdb3f8ddf 100644 --- a/packages/services/server/src/environment.ts +++ b/packages/services/server/src/environment.ts @@ -32,6 +32,7 @@ const EnvironmentModel = zod.object({ WEBHOOKS_ENDPOINT: zod.string().url(), SCHEMA_ENDPOINT: zod.string().url(), AUTH_ORGANIZATION_OIDC: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()), + GRAPHQL_PERSISTED_OPERATIONS_PATH: emptyString(zod.string().optional()), }); const SentryModel = zod.union([ @@ -350,4 +351,7 @@ export const env = { } : null, hive: hiveConfig, + graphql: { + persistedOperationsPath: base.GRAPHQL_PERSISTED_OPERATIONS_PATH ?? null, + }, } as const; diff --git a/packages/services/server/src/graphql-handler.ts b/packages/services/server/src/graphql-handler.ts index 20de92980..4be74a377 100644 --- a/packages/services/server/src/graphql-handler.ts +++ b/packages/services/server/src/graphql-handler.ts @@ -5,7 +5,7 @@ import type { FastifyRequest, RouteHandlerMethod, } from 'fastify'; -import { GraphQLError, print, ValidationContext, ValidationRule } from 'graphql'; +import { DocumentNode, GraphQLError, print, ValidationContext, ValidationRule } from 'graphql'; import { createYoga, Plugin, useErrorHandler } from 'graphql-yoga'; import hyperid from 'hyperid'; import zod from 'zod'; @@ -14,6 +14,7 @@ import { useGenericAuth } from '@envelop/generic-auth'; import { useGraphQLModules } from '@envelop/graphql-modules'; import { useSentry } from '@envelop/sentry'; import { useYogaHive } from '@graphql-hive/client'; +import { usePersistedOperations } from '@graphql-yoga/plugin-persisted-operations'; import { useResponseCache } from '@graphql-yoga/plugin-response-cache'; import { HiveError, Registry, RegistryContext } from '@hive/api'; import { cleanRequestId } from '@hive/service-common'; @@ -54,6 +55,7 @@ export interface GraphQLHandlerOptions { hiveConfig: HiveConfig; release: string; logger: FastifyLoggerInstance; + persistedOperations: Record | null; } export type SuperTokenSessionPayload = zod.TypeOf; @@ -98,9 +100,19 @@ function useNoIntrospection(params: { } export const graphqlHandler = (options: GraphQLHandlerOptions): RouteHandlerMethod => { + const { persistedOperations } = options; const server = createYoga({ logging: options.logger, plugins: [ + persistedOperations + ? usePersistedOperations({ + allowArbitraryOperations: true, + skipDocumentValidation: true, + getPersistedOperation(key) { + return persistedOperations[key] ?? null; + }, + }) + : {}, useArmor(), useSentry({ startTransaction: false, diff --git a/packages/services/server/src/index.ts b/packages/services/server/src/index.ts index c0c633de2..6e30e7593 100644 --- a/packages/services/server/src/index.ts +++ b/packages/services/server/src/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node +import * as fs from 'fs'; import got from 'got'; -import { GraphQLError, stripIgnoredCharacters } from 'graphql'; +import { DocumentNode, GraphQLError, stripIgnoredCharacters } from 'graphql'; import 'reflect-metadata'; import zod from 'zod'; import { createRegistry, CryptoProvider, LogFn, Logger } from '@hive/api'; @@ -244,6 +245,13 @@ export async function main() { organizationOIDC: env.organizationOIDC, }); + let persistedOperations: Record | null = null; + if (env.graphql.persistedOperationsPath) { + persistedOperations = JSON.parse( + fs.readFileSync(env.graphql.persistedOperationsPath, 'utf-8'), + ); + } + const graphqlPath = '/graphql'; const port = env.http.port; const signature = Math.random().toString(16).substr(2); @@ -259,6 +267,7 @@ export async function main() { release: env.release, hiveConfig: env.hive, logger: graphqlLogger as any, + persistedOperations, }); server.route({ diff --git a/packages/web/app/README.md b/packages/web/app/README.md index 3202d1f6b..cb4441afc 100644 --- a/packages/web/app/README.md +++ b/packages/web/app/README.md @@ -38,6 +38,7 @@ The following environment variables configure the application. | `NODE_ENV` | No | The `NODE_ENV` value. | `production` | | `GA_TRACKING_ID` | No | The token for Google Analytics in order to track user actions. | `g6aff8102efda5e1d12e` | | `CRISP_WEBSITE_ID` | No | The Crisp Website ID | `g6aff8102efda5e1d12e` | +| `GRAPHQL_PERSISTED_OPERATIONS` | No | Send persisted oepration hashes instead of documents to the server. | `1` (enabled) or `0` (disabled) | ## Hive Hosted Configuration diff --git a/packages/web/app/environment.ts b/packages/web/app/environment.ts index 666763572..da815a8fd 100644 --- a/packages/web/app/environment.ts +++ b/packages/web/app/environment.ts @@ -38,6 +38,9 @@ const BaseSchema = zod.object({ AUTH_REQUIRE_EMAIL_VERIFICATION: emptyString( zod.union([zod.literal('1'), zod.literal('0')]).optional(), ), + GRAPHQL_PERSISTED_OPERATIONS: emptyString( + zod.union([zod.literal('1'), zod.literal('0')]).optional(), + ), }); const IntegrationSlackSchema = zod.union([ @@ -231,6 +234,9 @@ const config = { }, sentry: sentry.SENTRY === '1' ? { dsn: sentry.SENTRY_DSN } : null, stripePublicKey: base.STRIPE_PUBLIC_KEY ?? null, + graphql: { + persistedOperations: base.GRAPHQL_PERSISTED_OPERATIONS === '1', + }, } as const; declare global { diff --git a/packages/web/app/package.json b/packages/web/app/package.json index c33d3f653..918855d1a 100644 --- a/packages/web/app/package.json +++ b/packages/web/app/package.json @@ -47,6 +47,7 @@ "@urql/core": "4.0.10", "@urql/devtools": "2.0.3", "@urql/exchange-graphcache": "6.1.4", + "@urql/exchange-persisted": "4.0.1", "@whatwg-node/fetch": "0.9.9", "class-variance-authority": "0.6.0", "clsx": "1.2.1", diff --git a/packages/web/app/src/config/frontend-env.ts b/packages/web/app/src/config/frontend-env.ts index d63f3280e..5b3531abf 100644 --- a/packages/web/app/src/config/frontend-env.ts +++ b/packages/web/app/src/config/frontend-env.ts @@ -33,6 +33,9 @@ export const env = { release: backendEnv.release, environment: backendEnv.environment, nodeEnv: backendEnv.nodeEnv, + graphql: { + persistedOperations: backendEnv.graphql.persistedOperations, + }, } as const; declare global { diff --git a/packages/web/app/src/lib/urql.ts b/packages/web/app/src/lib/urql.ts index bd1c0d510..355d82bed 100644 --- a/packages/web/app/src/lib/urql.ts +++ b/packages/web/app/src/lib/urql.ts @@ -1,5 +1,7 @@ import { createClient, errorExchange, fetchExchange } from 'urql'; +import { env } from '@/env/frontend'; import { cacheExchange } from '@urql/exchange-graphcache'; +import { persistedExchange } from '@urql/exchange-persisted'; import { Mutation } from './urql-cache'; import { networkStatusExchange } from './urql-exchanges/state'; @@ -7,6 +9,8 @@ const noKey = (): null => null; const SERVER_BASE_PATH = '/api/proxy'; +const isSome = (value: T | null | undefined): value is T => value != null; + export const urqlClient = createClient({ url: SERVER_BASE_PATH, exchanges: [ @@ -45,6 +49,7 @@ export const urqlClient = createClient({ }, globalIDs: ['SuccessfulSchemaCheck', 'FailedSchemaCheck'], }), + networkStatusExchange, errorExchange({ onError(error) { if (error.response?.status === 401) { @@ -52,7 +57,16 @@ export const urqlClient = createClient({ } }, }), - networkStatusExchange, + env.graphql.persistedOperations + ? persistedExchange({ + enforcePersistedQueries: true, + enableForMutation: true, + generateHash: (_, document) => { + // TODO: improve types here + return Promise.resolve((document as any)?.['__meta__']?.['hash'] ?? ''); + }, + }) + : null, fetchExchange, - ].filter(Boolean), + ].filter(isSome), }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37a07ec29..c10e89ddf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1018,6 +1018,9 @@ importers: '@escape.tech/graphql-armor-max-tokens': specifier: 2.1.0 version: 2.1.0 + '@graphql-yoga/plugin-persisted-operations': + specifier: 2.0.3 + version: 2.0.3(@graphql-tools/utils@10.0.3)(graphql-yoga@4.0.3)(graphql@16.6.0) '@graphql-yoga/plugin-response-cache': specifier: 2.1.0 version: 2.1.0(@envelop/core@4.0.0)(graphql-yoga@4.0.3)(graphql@16.6.0) @@ -1546,6 +1549,9 @@ importers: '@urql/exchange-graphcache': specifier: 6.1.4 version: 6.1.4(graphql@16.6.0) + '@urql/exchange-persisted': + specifier: 4.0.1 + version: 4.0.1(graphql@16.6.0) '@whatwg-node/fetch': specifier: 0.9.9 version: 0.9.9 @@ -7615,6 +7621,19 @@ packages: dependencies: tslib: 2.5.3 + /@graphql-yoga/plugin-persisted-operations@2.0.3(@graphql-tools/utils@10.0.3)(graphql-yoga@4.0.3)(graphql@16.6.0): + resolution: {integrity: sha512-2MWcNiwetxlfpUuwGpf9YvYfWalOHckRA81DRdWQtys4qRpozPJ5Zt+ucEEmrCtpqhloudbmXjW2aXnrLVUcQQ==} + engines: {node: '>=16.0.0'} + peerDependencies: + '@graphql-tools/utils': ^10.0.0 + graphql: ^15.2.0 || ^16.0.0 + graphql-yoga: ^4.0.3 + dependencies: + '@graphql-tools/utils': 10.0.3(graphql@16.6.0) + graphql: 16.6.0 + graphql-yoga: 4.0.3(graphql@16.6.0) + dev: false + /@graphql-yoga/plugin-response-cache@2.1.0(@envelop/core@4.0.0)(graphql-yoga@4.0.3)(graphql@16.6.0): resolution: {integrity: sha512-QMmVL5Sx2mEQ6pejj0pQ6F2QcfIiMYibE1xSdhWAJkDfQlImAcd3uv1smMBbUMp3GNRF3vs1K5SpIzX/UHva4g==} engines: {node: '>=16.0.0'} @@ -13009,6 +13028,15 @@ packages: - graphql dev: false + /@urql/exchange-persisted@4.0.1(graphql@16.6.0): + resolution: {integrity: sha512-EG0835VoNC4a1Tjv58z0V01/jX1L3+TEEEglI8rTMfE0KUdGKpqel6vqz2W6L52RdGiZgh0cY+wgfy/xAZT2ug==} + dependencies: + '@urql/core': 4.0.10(graphql@16.6.0) + wonka: 6.3.2 + transitivePeerDependencies: + - graphql + dev: false + /@vitest/expect@0.31.1: resolution: {integrity: sha512-BV1LyNvhnX+eNYzJxlHIGPWZpwJFZaCcOIzp2CNG0P+bbetenTupk6EO0LANm4QFt0TTit+yqx7Rxd1qxi/SQA==} dependencies: diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index 4fee81071..4dab129de 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -15,7 +15,9 @@ ".eslintrc.cjs", "vitest.config.ts", "scripts/serializer.ts", - "packages/web/app/.babelrc.cjs" + "packages/web/app/.babelrc.cjs", + "configs", + "codegen.mts" ], "exclude": ["**/node_modules/**", "**/dist", "**/temp", "**/tmp"] }