Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/presets/near-operation-file/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@graphql-codegen/add": "^3.2.1",
"@graphql-codegen/plugin-helpers": "^3.0.0",
"@graphql-codegen/visitor-plugin-common": "2.13.1",
"@graphql-tools/documents": "^1.0.0",
"@graphql-tools/utils": "^10.0.0",
"parse-filepath": "^1.0.2",
"tslib": "~2.6.0"
Expand Down
119 changes: 119 additions & 0 deletions packages/presets/near-operation-file/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
resolveDocumentImports,
} from './resolve-document-imports.js';
import { appendFileNameToFilePath, defineFilepathSubfolder } from './utils.js';
import { generateDocumentHash, normalizeAndPrintDocumentNode } from './persisted-documents.js';

export { resolveDocumentImports, DocumentImportResolverOptions };

Expand Down Expand Up @@ -200,6 +201,61 @@ export type NearOperationFileConfig = {
* ```
*/
importTypesNamespace?: string;
/**
* @description Optional, enables persisted operations support.
* When enabled, it will generate a persisted-documents.json file containing operation hashes.
* @default false
*
* @exampleMarkdown
* ```ts filename="codegen.ts" { 11 }
* import type { CodegenConfig } from '@graphql-codegen/cli';
*
* const config: CodegenConfig = {
* // ...
* generates: {
* 'path/to/file.ts': {
* preset: 'near-operation-file',
* plugins: ['typescript-operations'],
* presetConfig: {
* baseTypesPath: 'types.ts',
* persistedDocuments: {
* hashPropertyName: 'hash',
* mode: 'embedHashInDocument',
* hashAlgorithm: 'sha256'
* }
* },
* },
* },
* };
* export default config;
* ```
*/
persistedDocuments?:
| boolean
| {
/**
* @description Behavior for the output file.
* @default 'embedHashInDocument'
* "embedHashInDocument" will add a property within the `DocumentNode` with the hash of the operation.
* "replaceDocumentWithHash" will fully drop the document definition.
*/
mode?: 'embedHashInDocument' | 'replaceDocumentWithHash';
/**
* @description Name of the property that will be added to the `DocumentNode` with the hash of the operation.
*/
hashPropertyName?: string;
/**
* @description Algorithm or function used to generate the hash, could be useful if your server expects something specific (e.g., Apollo Server expects `sha256`).
*
* A custom hash function can be provided to generate the hash if the preset algorithms don't fit your use case. The function receives the operation and should return the hash string.
*
* The algorithm parameter is typed with known algorithms and as a string rather than a union because it solely depends on Crypto's algorithms supported
* by the version of OpenSSL on the platform.
*
* @default `sha1`
*/
hashAlgorithm?: 'sha1' | 'sha256' | (string & {}) | ((operation: string) => string);
};
};

export type FragmentNameToFile = {
Expand Down Expand Up @@ -293,6 +349,24 @@ export const preset: Types.OutputPreset<NearOperationFileConfig> = {
}

const artifacts: Array<Types.GenerateOptions> = [];
const persistedDocumentsMap = new Map<string, string>();

// Handle persisted documents configuration
const persistedDocuments = options.presetConfig.persistedDocuments
? {
hashPropertyName:
(typeof options.presetConfig.persistedDocuments === 'object' &&
options.presetConfig.persistedDocuments.hashPropertyName) ||
'hash',
omitDefinitions:
(typeof options.presetConfig.persistedDocuments === 'object' &&
options.presetConfig.persistedDocuments.mode) === 'replaceDocumentWithHash' || false,
hashAlgorithm:
(typeof options.presetConfig.persistedDocuments === 'object' &&
options.presetConfig.persistedDocuments.hashAlgorithm) ||
'sha1',
}
: null;

for (const [filename, record] of filePathsMap.entries()) {
let fragmentImportsArr = record.fragmentImports;
Expand Down Expand Up @@ -363,6 +437,25 @@ export const preset: Types.OutputPreset<NearOperationFileConfig> = {
(combinedSource.document.definitions as any).push(...source.document.definitions);
}

// Handle persisted documents
if (persistedDocuments) {
const documentString = normalizeAndPrintDocumentNode(combinedSource.document);
const hash = generateDocumentHash(documentString, persistedDocuments.hashAlgorithm);
persistedDocumentsMap.set(hash, documentString);

if (!persistedDocuments.omitDefinitions) {
// Add hash to document
(combinedSource.document as any)[persistedDocuments.hashPropertyName] = hash;
} else {
// Replace document with hash
combinedSource.document = {
kind: Kind.DOCUMENT,
definitions: [],
[persistedDocuments.hashPropertyName]: hash,
};
}
}

artifacts.push({
...options,
filename,
Expand All @@ -379,6 +472,32 @@ export const preset: Types.OutputPreset<NearOperationFileConfig> = {
});
}

// Add persisted-documents.json if enabled
if (persistedDocuments && persistedDocumentsMap.size > 0) {
artifacts.push({
filename: join(options.baseOutputDir, 'persisted-documents.json'),
plugins: [
{
[`persisted-operations`]: {},
},
],
pluginMap: {
[`persisted-operations`]: {
plugin: async () => {
//await tdnFinished.promise;
Copy link
Author

@Shane32 Shane32 Nov 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cline removed this line, so I added it back as a comment. Seems like client-preset did some kind of plugin map on typed-document-node, hooking into it so that this promise would run after typed-document-node finished. Not really sure if that is applicable here or how to integrate it.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For reference, here's the code from client-preset:

    const tdnFinished = createDeferred();
    const persistedDocumentsMap = new Map<string, string>();

    const pluginMap = {
      ...options.pluginMap,
      [`add`]: addPlugin,
      [`typescript`]: typescriptPlugin,
      [`typescript-operations`]: typescriptOperationPlugin,
      [`typed-document-node`]: {
        ...typedDocumentNodePlugin,
        plugin: async (...args: Parameters<PluginFunction>) => {
          try {
            return await typedDocumentNodePlugin.plugin(...args);
          } finally {
            tdnFinished.resolve();
          }
        },
      },
      [`gen-dts`]: gqlTagPlugin,
    };

return {
content: JSON.stringify(Object.fromEntries(persistedDocumentsMap.entries()), null, 2),
};
},
},
},
schema: options.schema,
config: {},
documents: [],
skipDocumentsValidation: true,
});
}

return artifacts;
},
};
Expand Down
44 changes: 44 additions & 0 deletions packages/presets/near-operation-file/src/persisted-documents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { printExecutableGraphQLDocument } from '@graphql-tools/documents';
import * as crypto from 'crypto';
import { Kind, visit, type DocumentNode } from 'graphql';

/**
* This function generates a hash from a document node.
*/
export function generateDocumentHash(
operation: string,
algorithm: 'sha1' | 'sha256' | (string & {}) | ((operation: string) => string)
): string {
if (typeof algorithm === 'function') {
return algorithm(operation);
}
const shasum = crypto.createHash(algorithm);
shasum.update(operation);
return shasum.digest('hex');
}

/**
* Normalizes and prints a document node.
*/
export function normalizeAndPrintDocumentNode(documentNode: DocumentNode): string {
/**
* This removes all client specific directives/fields from the document
* that the server does not know about.
* In a future version this should be more configurable.
* If you look at this and want to customize it.
* Send a PR :)
*/
const sanitizedDocument = visit(documentNode, {
[Kind.FIELD](field) {
if (field.directives?.some(directive => directive.name.value === 'client')) {
return null;
}
},
[Kind.DIRECTIVE](directive) {
if (directive.name.value === 'connection') {
return null;
}
},
});
return printExecutableGraphQLDocument(sanitizedDocument);
}