diff --git a/.changeset/friendly-pens-marry.md b/.changeset/friendly-pens-marry.md new file mode 100644 index 0000000000..2c74a6bc6b --- /dev/null +++ b/.changeset/friendly-pens-marry.md @@ -0,0 +1,5 @@ +--- +'@shopify/hydrogen-codegen': minor +--- + +Export type utilities to create GraphQL clients that can consume the types generated by this package. diff --git a/package-lock.json b/package-lock.json index 3e5577ae2e..38df4a5c6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13340,9 +13340,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.614", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.614.tgz", - "integrity": "sha512-X4ze/9Sc3QWs6h92yerwqv7aB/uU8vCjZcrMjA8N9R1pjMFRe44dLsck5FzLilOYvcXuDn93B+bpGYyufc70gQ==" + "version": "1.4.615", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.615.tgz", + "integrity": "sha512-/bKPPcgZVUziECqDc+0HkT87+0zhaWSZHNXqF8FLd2lQcptpmUFwoCSWjCdOng9Gdq+afKArPdEg/0ZW461Eng==" }, "node_modules/emoji-regex": { "version": "9.2.2", @@ -31338,7 +31338,7 @@ "fast-glob": "^3.2.12", "flame-chart-js": "2.3.1", "get-port": "^7.0.0", - "type-fest": "^3.6.0", + "type-fest": "^4.5.0", "vitest": "^1.0.4" }, "engines": { @@ -31369,6 +31369,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/cli/node_modules/ansi-escapes/node_modules/type-fest": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", + "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/cli/node_modules/ansi-regex": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", @@ -31492,11 +31503,12 @@ } }, "packages/cli/node_modules/type-fest": { - "version": "3.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", - "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.8.3.tgz", + "integrity": "sha512-//BaTm14Q/gHBn09xlnKNqfI8t6bmdzx2DXYfPBNofN0WUybCEUDcbCWcTa0oF09lzLjZgPphXAsvRiMK0V6Bw==", + "dev": true, "engines": { - "node": ">=14.16" + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -31544,6 +31556,7 @@ }, "devDependencies": { "@shopify/generate-docs": "0.11.1", + "@shopify/hydrogen-codegen": "*", "@testing-library/react": "^14.0.0", "happy-dom": "^8.9.0", "react": "^18.2.0", @@ -31563,7 +31576,8 @@ "dependencies": { "@graphql-codegen/add": "^5.0.0", "@graphql-codegen/typescript": "^4.0.1", - "@graphql-codegen/typescript-operations": "^4.0.1" + "@graphql-codegen/typescript-operations": "^4.0.1", + "type-fest": "^4.5.0" }, "devDependencies": { "@graphql-codegen/cli": "^5.0.0", @@ -31575,6 +31589,17 @@ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, + "packages/hydrogen-codegen/node_modules/type-fest": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.8.3.tgz", + "integrity": "sha512-//BaTm14Q/gHBn09xlnKNqfI8t6bmdzx2DXYfPBNofN0WUybCEUDcbCWcTa0oF09lzLjZgPphXAsvRiMK0V6Bw==", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/hydrogen-react": { "name": "@shopify/hydrogen-react", "version": "2023.10.1", @@ -37643,7 +37668,7 @@ "stack-trace": "^1.0.0-pre2", "tar-fs": "^2.1.1", "ts-morph": "20.0.0", - "type-fest": "^3.6.0", + "type-fest": "^4.5.0", "use-resize-observer": "^9.1.0", "vitest": "^1.0.4", "ws": "^8.13.0" @@ -37653,6 +37678,13 @@ "version": "6.2.0", "requires": { "type-fest": "^3.0.0" + }, + "dependencies": { + "type-fest": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", + "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==" + } } }, "ansi-regex": { @@ -37726,9 +37758,10 @@ } }, "type-fest": { - "version": "3.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", - "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==" + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.8.3.tgz", + "integrity": "sha512-//BaTm14Q/gHBn09xlnKNqfI8t6bmdzx2DXYfPBNofN0WUybCEUDcbCWcTa0oF09lzLjZgPphXAsvRiMK0V6Bw==", + "dev": true }, "ws": { "version": "8.15.1", @@ -38024,6 +38057,7 @@ "version": "file:packages/hydrogen", "requires": { "@shopify/generate-docs": "0.11.1", + "@shopify/hydrogen-codegen": "*", "@shopify/hydrogen-react": "2023.10.1", "@testing-library/react": "^14.0.0", "content-security-policy-builder": "^2.1.1", @@ -38050,7 +38084,15 @@ "@graphql-codegen/typescript": "^4.0.1", "@graphql-codegen/typescript-operations": "^4.0.1", "@graphql-tools/utils": "^10.0.3", + "type-fest": "^4.5.0", "vitest": "^1.0.4" + }, + "dependencies": { + "type-fest": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.8.3.tgz", + "integrity": "sha512-//BaTm14Q/gHBn09xlnKNqfI8t6bmdzx2DXYfPBNofN0WUybCEUDcbCWcTa0oF09lzLjZgPphXAsvRiMK0V6Bw==" + } } }, "@shopify/hydrogen-react": { @@ -41732,9 +41774,9 @@ } }, "electron-to-chromium": { - "version": "1.4.614", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.614.tgz", - "integrity": "sha512-X4ze/9Sc3QWs6h92yerwqv7aB/uU8vCjZcrMjA8N9R1pjMFRe44dLsck5FzLilOYvcXuDn93B+bpGYyufc70gQ==" + "version": "1.4.615", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.615.tgz", + "integrity": "sha512-/bKPPcgZVUziECqDc+0HkT87+0zhaWSZHNXqF8FLd2lQcptpmUFwoCSWjCdOng9Gdq+afKArPdEg/0ZW461Eng==" }, "emoji-regex": { "version": "9.2.2" diff --git a/packages/cli/package.json b/packages/cli/package.json index c77bdbc6cf..178354f23d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -28,7 +28,7 @@ "fast-glob": "^3.2.12", "flame-chart-js": "2.3.1", "get-port": "^7.0.0", - "type-fest": "^3.6.0", + "type-fest": "^4.5.0", "vitest": "^1.0.4" }, "dependencies": { diff --git a/packages/hydrogen-codegen/package.json b/packages/hydrogen-codegen/package.json index 6aa40942ec..419d583d38 100644 --- a/packages/hydrogen-codegen/package.json +++ b/packages/hydrogen-codegen/package.json @@ -15,8 +15,8 @@ "build": "tsup --clean", "dev": "tsup --watch", "typecheck": "tsc --noEmit", - "test": "cross-env SHOPIFY_UNIT_TEST=1 vitest run", - "test:watch": "cross-env SHOPIFY_UNIT_TEST=1 vitest" + "test": "cross-env SHOPIFY_UNIT_TEST=1 vitest run --typecheck", + "test:watch": "cross-env SHOPIFY_UNIT_TEST=1 vitest --typecheck" }, "exports": { ".": { @@ -51,7 +51,8 @@ "dependencies": { "@graphql-codegen/add": "^5.0.0", "@graphql-codegen/typescript": "^4.0.1", - "@graphql-codegen/typescript-operations": "^4.0.1" + "@graphql-codegen/typescript-operations": "^4.0.1", + "type-fest": "^4.5.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" diff --git a/packages/hydrogen-codegen/src/client.ts b/packages/hydrogen-codegen/src/client.ts new file mode 100644 index 0000000000..5070db6db2 --- /dev/null +++ b/packages/hydrogen-codegen/src/client.ts @@ -0,0 +1,128 @@ +/** + * This file has utilities to create GraphQL clients + * that consume the types generated by the Hydrogen preset. + */ + +import type {ExecutionArgs} from 'graphql'; +import type {SetOptional, IsNever} from 'type-fest'; + +/** + * A generic type for `variables` in GraphQL clients + */ +export type GenericVariables = ExecutionArgs['variableValues']; + +/** + * Use this type to make parameters optional in GraphQL clients + * when no variables need to be passed. + */ +export type EmptyVariables = {[key: string]: never}; + +/** + * GraphQL client's generic operation interface. + */ +interface CodegenOperations { + // Real example: + // '#graphql\n query TestQuery { test }': {return: R; variables: V}; + // -- + // However, since the interface passed as CodegenOperations might sitll be empty + // (if the user hasn't generated the code yet), we use `any` here. + [key: string]: any; +} + +/** + * Used as the return type for GraphQL clients. It picks + * the return type from the generated operation types. + * @example + * graphqlQuery: (...) => Promise> + * graphqlQuery: (...) => Promise<{data: ClientReturn<...>}> + */ +export type ClientReturn< + GeneratedOperations extends CodegenOperations, + RawGqlString extends string, + OverrideReturnType extends any = never, +> = IsNever extends true + ? // Nothing passed to override the return type + RawGqlString extends keyof GeneratedOperations + ? // Known query, use generated return type + GeneratedOperations[RawGqlString]['return'] + : // Unknown query, return 'any' to avoid red squiggly underlines in editor + any + : // Override return type is passed, use it + OverrideReturnType; + +/** + * Checks if the generated variables for an operation + * are optional or required. + */ +export type IsOptionalVariables< + VariablesParam, + OptionalVariableNames extends string = never, + // The following are just extracted repeated types, not parameters: + VariablesWithoutOptionals = Omit, +> = VariablesWithoutOptionals extends EmptyVariables + ? // No expected required variables, object is optional + true + : GenericVariables extends VariablesParam + ? // We don't have information about the variables, so we assume object is optional + true + : Partial extends VariablesWithoutOptionals + ? // All known variables are optional, object is optional + true + : // Some known variables are required, object is required + false; + +/** + * Used as the type for the GraphQL client's variables. It checks + * the generated operation types to see if variables are optional. + * @example + * graphqlQuery: (query: string, param: ClientVariables<...>) => Promise<...> + * Where `param` is required. + */ +export type ClientVariables< + GeneratedOperations extends CodegenOperations, + RawGqlString extends string, + OptionalVariableNames extends string = never, + VariablesKey extends string = 'variables', + // The following are just extracted repeated types, not parameters: + GeneratedVariables = RawGqlString extends keyof GeneratedOperations + ? SetOptional< + GeneratedOperations[RawGqlString]['variables'], + Extract< + keyof GeneratedOperations[RawGqlString]['variables'], + OptionalVariableNames + > + > + : GenericVariables, + VariablesWrapper = Record, +> = IsOptionalVariables extends true + ? // Variables are all optional: object wrapper is optional + Partial + : // Some variables are required: object wrapper is required + VariablesWrapper; + +/** + * Similar to ClientVariables, but makes the whole wrapper optional: + * @example + * graphqlQuery: (query: string, ...params: ClientVariablesInRestParams<...>) => Promise<...> + * Where the first item in `params` might be optional depending on the query. + */ +export type ClientVariablesInRestParams< + GeneratedOperations extends CodegenOperations, + RawGqlString extends string, + OtherParams extends Record = {}, + OptionalVariableNames extends string = never, + // The following are just extracted repeated types, not parameters: + ProcessedVariables = OtherParams & + ClientVariables, +> = Partial extends OtherParams + ? // No required keys in OtherParams: keep checking + IsOptionalVariables< + GeneratedOperations[RawGqlString]['variables'], + OptionalVariableNames + > extends true + ? // No required keys in OtherParams and variables are also optional: rest param is optional + [ProcessedVariables?] + : // No required keys in OtherParams but variables are required: rest param is required + [ProcessedVariables] + : // There are required keys in OtherParams: rest param is required + [ProcessedVariables]; diff --git a/packages/hydrogen-codegen/src/index.ts b/packages/hydrogen-codegen/src/index.ts index 3028c49210..a10803e914 100644 --- a/packages/hydrogen-codegen/src/index.ts +++ b/packages/hydrogen-codegen/src/index.ts @@ -3,3 +3,4 @@ export {plugin} from './plugin.js'; export {schema, getSchema} from './schema.js'; export {processSources} from './sources.js'; export {pluckConfig} from './pluck.js'; +export type * from './client.js'; diff --git a/packages/hydrogen-codegen/tests/client.test-d.ts b/packages/hydrogen-codegen/tests/client.test-d.ts new file mode 100644 index 0000000000..6b9ff05ef5 --- /dev/null +++ b/packages/hydrogen-codegen/tests/client.test-d.ts @@ -0,0 +1,319 @@ +import {describe, it, expectTypeOf} from 'vitest'; +import type { + ClientReturn, + ClientVariablesInRestParams, + GenericVariables, +} from '../src/index.js'; + +enum Queries { + Unknown = '#graphql\n query UnknownQuery { test }', + Simple = '#graphql\n query Test1Query { test }', + WithRequiredVars = '#graphql\n query Test2Query($id: ID!) { test }', + WithOptionalVars = '#graphql\n query Test2Query($id: ID) { test }', + WithAutoAddedVars = '#graphql\n query Test2Query($country: CountryCode!, $language: LanguageCode) { test }', +} + +interface GeneratedQueryTypes { + [Queries.Simple]: { + return: {test: number}; + variables: {}; + }; + [Queries.WithRequiredVars]: { + return: {test: number}; + variables: {id: string}; + }; + [Queries.WithOptionalVars]: { + return: {test: number}; + variables: {id?: string}; + }; + [Queries.WithAutoAddedVars]: { + return: {test: number}; + // One is required, one is optional. + // However, since these are auto added, + // both should become optional at the end. + variables: {country: string; language?: string}; + }; +} + +describe('Client types', async () => { + describe('ClientReturn', () => { + const clientQuery = < + OverrideReturnType extends any = never, + RawGqlString extends string = string, + >( + query: RawGqlString, + ) => + Promise.resolve() as Promise< + ClientReturn + >; + + it('finds the return type from query', async () => { + expectTypeOf(clientQuery(Queries.Simple)).resolves.toEqualTypeOf<{ + test: number; + }>(); + expectTypeOf(clientQuery(Queries.Simple)).resolves.not.toEqualTypeOf<{ + test: string; + }>(); + }); + + it('fallsback to any for unknown queries', () => { + expectTypeOf(clientQuery(Queries.Unknown)).resolves.not.toEqualTypeOf<{ + test: number; + }>(); + expectTypeOf(clientQuery(Queries.Unknown)).resolves.toEqualTypeOf(); + }); + + it('can be overriden', async () => { + // Non-recognized query, override return type + expectTypeOf( + clientQuery<{test: string}>(Queries.Unknown), + ).resolves.toEqualTypeOf<{test: string}>(); + + // Recognized query, override return type + expectTypeOf( + clientQuery<{test: string}>(Queries.Simple), + ).resolves.toEqualTypeOf<{ + test: string; + }>(); + expectTypeOf( + clientQuery<{test: string}>(Queries.Simple), + ).resolves.not.toEqualTypeOf<{test: number}>(); + }); + }); + + describe('ClientVariablesInRestParams', () => { + describe('when there are not extra params', () => { + const clientQuery = < + OverrideReturnType extends any = never, + RawGqlString extends string = string, + >( + query: RawGqlString, + ...options: ClientVariablesInRestParams< + GeneratedQueryTypes, + RawGqlString, + {}, // No extra params, only 'variables' + 'country' | 'language' + > + ) => Promise.resolve(); + + it('finds GenericVariables for unknown queries', async () => { + expectTypeOf(clientQuery) + .parameter(0) + .toEqualTypeOf(); + + expectTypeOf(clientQuery) + .parameter(1) + .toEqualTypeOf<{variables?: GenericVariables} | undefined>(); + + expectTypeOf(clientQuery) + .parameter(2) + .toEqualTypeOf(); + }); + + it('finds empty variables for known queries without variables', async () => { + expectTypeOf(clientQuery) + .parameter(0) + .toEqualTypeOf(); + + expectTypeOf(clientQuery) + .parameter(1) + .toEqualTypeOf<{variables?: {}} | undefined>(); + }); + + it('finds the variables type from known queries with required variables', async () => { + expectTypeOf(clientQuery) + .parameter(0) + .toEqualTypeOf(); + + expectTypeOf(clientQuery) + .parameter(1) + .toEqualTypeOf<{variables: {id: string}}>(); + }); + + it('finds the partial variables type from known queries with optional variables', async () => { + expectTypeOf(clientQuery) + .parameter(0) + .toEqualTypeOf(); + expectTypeOf(clientQuery) + .parameter(1) + .toEqualTypeOf<{variables?: {id?: string}} | undefined>(); + }); + + it('finds the partial variables type from known queries with required auto-added variables', async () => { + expectTypeOf(clientQuery) + .parameter(0) + .toEqualTypeOf(); + + expectTypeOf(clientQuery) + .parameter(1) + .toEqualTypeOf< + // Auto-added, therefore all optional: + {variables?: {country?: string; language?: string}} | undefined + >(); + }); + }); + + describe('when there are optional extra params', () => { + type WithExtraParam = {extraParam?: number}; + + const clientQuery = < + OverrideReturnType extends any = never, + RawGqlString extends string = string, + >( + query: RawGqlString, + ...options: ClientVariablesInRestParams< + GeneratedQueryTypes, + RawGqlString, + WithExtraParam, + 'country' | 'language' + > + ) => Promise.resolve(); + + it('finds GenericVariables for unknown queries', async () => { + expectTypeOf(clientQuery) + .parameter(0) + .toEqualTypeOf(); + + expectTypeOf(clientQuery) + .parameter(1) + .toEqualTypeOf< + | (WithExtraParam & { + variables?: GenericVariables; + }) + | undefined + >(); + + expectTypeOf(clientQuery) + .parameter(2) + .toEqualTypeOf(); + }); + + it('finds empty variables for known queries without variables', async () => { + expectTypeOf(clientQuery) + .parameter(0) + .toEqualTypeOf(); + + expectTypeOf(clientQuery) + .parameter(1) + .toEqualTypeOf<(WithExtraParam & {variables?: {}}) | undefined>(); + }); + + it('finds the variables type from known queries with required variables', async () => { + expectTypeOf(clientQuery) + .parameter(0) + .toEqualTypeOf(); + + expectTypeOf(clientQuery) + .parameter(1) + .toEqualTypeOf(); + }); + + it('finds the partial variables type from known queries with optional variables', async () => { + expectTypeOf(clientQuery) + .parameter(0) + .toEqualTypeOf(); + expectTypeOf(clientQuery) + .parameter(1) + .toEqualTypeOf< + (WithExtraParam & {variables?: {id?: string}}) | undefined + >(); + }); + + it('finds the partial variables type from known queries with required auto-added variables', async () => { + expectTypeOf(clientQuery) + .parameter(0) + .toEqualTypeOf(); + + expectTypeOf(clientQuery) + .parameter(1) + .toEqualTypeOf< + // Auto-added variables and an optional extra param, therefore all optional: + | (WithExtraParam & { + variables?: {country?: string; language?: string}; + }) + | undefined + >(); + }); + }); + + describe('when there are required extra params', () => { + type WithExtraParam = {extraParam: number}; + + const clientQuery = < + OverrideReturnType extends any = never, + RawGqlString extends string = string, + >( + query: RawGqlString, + ...options: ClientVariablesInRestParams< + GeneratedQueryTypes, + RawGqlString, + WithExtraParam, + 'country' | 'language' + > + ) => Promise.resolve(); + + it('finds GenericVariables for unknown queries', async () => { + expectTypeOf(clientQuery) + .parameter(0) + .toEqualTypeOf(); + + expectTypeOf(clientQuery) + .parameter(1) + .toEqualTypeOf< + | WithExtraParam & { + variables?: GenericVariables; + } + >(); + + expectTypeOf(clientQuery) + .parameter(2) + .toEqualTypeOf(); + }); + + it('finds empty variables for known queries without variables', async () => { + expectTypeOf(clientQuery) + .parameter(0) + .toEqualTypeOf(); + + expectTypeOf(clientQuery) + .parameter(1) + .toEqualTypeOf(); + }); + + it('finds the variables type from known queries with required variables', async () => { + expectTypeOf(clientQuery) + .parameter(0) + .toEqualTypeOf(); + + expectTypeOf(clientQuery) + .parameter(1) + .toEqualTypeOf(); + }); + + it('finds the partial variables type from known queries with optional variables', async () => { + expectTypeOf(clientQuery) + .parameter(0) + .toEqualTypeOf(); + expectTypeOf(clientQuery) + .parameter(1) + .toEqualTypeOf(); + }); + + it('finds the partial variables type from known queries with required auto-added variables', async () => { + expectTypeOf(clientQuery) + .parameter(0) + .toEqualTypeOf(); + + expectTypeOf(clientQuery) + .parameter(1) + .toEqualTypeOf< + // Auto-added variables and a required extra param, + // therefore variables are optional but wrapper is required: + WithExtraParam & { + variables?: {country?: string; language?: string}; + } + >(); + }); + }); + }); +}); diff --git a/packages/hydrogen-codegen/tsconfig.json b/packages/hydrogen-codegen/tsconfig.json index 4d501ce8e8..e877944060 100644 --- a/packages/hydrogen-codegen/tsconfig.json +++ b/packages/hydrogen-codegen/tsconfig.json @@ -1,10 +1,10 @@ { "extends": "../../tsconfig.json", - "include": ["src"], - "exclude": ["dist", "tests", "node_modules", ".turbo"], + "include": ["src", "tests"], + "exclude": ["dist", "node_modules", ".turbo"], "compilerOptions": { "moduleResolution": "node", - "rootDir": "src", + "rootDir": ".", "outDir": "dist" } } diff --git a/packages/hydrogen-codegen/tsup.config.ts b/packages/hydrogen-codegen/tsup.config.ts index 4fc8983ac3..b5dbeab07e 100644 --- a/packages/hydrogen-codegen/tsup.config.ts +++ b/packages/hydrogen-codegen/tsup.config.ts @@ -14,7 +14,9 @@ export default defineConfig([ { ...commonConfig, format: 'esm', - dts: true, + // Force bundling types in files here so that they can later be + // bundled in Hydrogen. Otherwise, TSUP fails to bundle them later. + dts: {entry: ['src/index.ts', 'src/patch.ts']}, entry: ['src/**/*.ts'], outDir: 'dist/esm', async onSuccess() { diff --git a/packages/hydrogen/package.json b/packages/hydrogen/package.json index a54f16d59b..78b66c5290 100644 --- a/packages/hydrogen/package.json +++ b/packages/hydrogen/package.json @@ -70,6 +70,7 @@ }, "devDependencies": { "@shopify/generate-docs": "0.11.1", + "@shopify/hydrogen-codegen": "*", "@testing-library/react": "^14.0.0", "happy-dom": "^8.9.0", "react": "^18.2.0", diff --git a/packages/hydrogen/src/storefront.ts b/packages/hydrogen/src/storefront.ts index 298606e40f..7dabfe447a 100644 --- a/packages/hydrogen/src/storefront.ts +++ b/packages/hydrogen/src/storefront.ts @@ -8,7 +8,6 @@ import { SHOPIFY_STOREFRONT_S_HEADER, type StorefrontClientProps, } from '@shopify/hydrogen-react'; -import type {ExecutionArgs} from 'graphql'; import {fetchWithServerCache, checkGraphQLErrors} from './cache/fetch'; import { SDK_VARIANT_HEADER, @@ -32,6 +31,11 @@ import { CountryCode, LanguageCode, } from '@shopify/hydrogen-react/storefront-api-types'; +import type { + ClientReturn, + ClientVariablesInRestParams, + GenericVariables, +} from '@shopify/hydrogen-codegen'; import {warnOnce} from './utils/warning'; import {LIB_VERSION} from './version'; import { @@ -71,75 +75,49 @@ export interface StorefrontMutations { // '#graphql mutation m1 {...}': {return: M1Mutation; variables: M1MutationVariables}; } -// Default type for `variables` in storefront client -type GenericVariables = ExecutionArgs['variableValues']; - -// Use this type to make parameters optional in storefront client -// when no variables need to be passed. -type EmptyVariables = {[key: string]: never}; - // These are the variables that are automatically added to the storefront API. // We use this type to make parameters optional in storefront client // when these are the only variables that can be passed. type AutoAddedVariableNames = 'country' | 'language'; -type IsOptionalVariables = Omit< - OperationTypeValue['variables'], - AutoAddedVariableNames -> extends EmptyVariables - ? true // No need to pass variables - : GenericVariables extends OperationTypeValue['variables'] - ? true // We don't know what variables are needed - : false; // Variables are known and required - -type StorefrontCommonOptions = { +type StorefrontCommonExtraParams = { headers?: HeadersInit; storefrontApiVersion?: string; -} & (IsOptionalVariables<{variables: Variables}> extends true - ? {variables?: Variables} - : {variables: Variables}); - -type StorefrontQuerySecondParam< - RawGqlString extends keyof StorefrontQueries | string = string, -> = (RawGqlString extends keyof StorefrontQueries - ? StorefrontCommonOptions - : StorefrontCommonOptions) & {cache?: CachingStrategy}; - -type StorefrontMutateSecondParam< - RawGqlString extends keyof StorefrontMutations | string = string, -> = RawGqlString extends keyof StorefrontMutations - ? StorefrontCommonOptions - : StorefrontCommonOptions; +}; /** * Interface to interact with the Storefront API. */ export type Storefront = { /** The function to run a query on Storefront API. */ - query: ( + query: < + OverrideReturnType extends any = never, + RawGqlString extends string = string, + >( query: RawGqlString, - ...options: RawGqlString extends keyof StorefrontQueries // Do we have any generated query types? - ? IsOptionalVariables extends true - ? [StorefrontQuerySecondParam?] // Using codegen, query has no variables - : [StorefrontQuerySecondParam] // Using codegen, query needs variables - : [StorefrontQuerySecondParam?] // No codegen, variables always optional + ...options: ClientVariablesInRestParams< + StorefrontQueries, + RawGqlString, + StorefrontCommonExtraParams & Pick, + AutoAddedVariableNames + > ) => Promise< - RawGqlString extends keyof StorefrontQueries // Do we have any generated query types? - ? StorefrontQueries[RawGqlString]['return'] // Using codegen, return type is known - : OverrideReturnType // No codegen, let user specify return type + ClientReturn >; /** The function to run a mutation on Storefront API. */ - mutate: ( + mutate: < + OverrideReturnType extends any = never, + RawGqlString extends string = string, + >( mutation: RawGqlString, - ...options: RawGqlString extends keyof StorefrontMutations // Do we have any generated mutation types? - ? IsOptionalVariables extends true - ? [StorefrontMutateSecondParam?] // Using codegen, mutation has no variables - : [StorefrontMutateSecondParam] // Using codegen, mutation needs variables - : [StorefrontMutateSecondParam?] // No codegen, variables always optional + ...options: ClientVariablesInRestParams< + StorefrontMutations, + RawGqlString, + StorefrontCommonExtraParams, + AutoAddedVariableNames + > ) => Promise< - RawGqlString extends keyof StorefrontMutations // Do we have any generated mutation types? - ? StorefrontMutations[RawGqlString]['return'] // Using codegen, return type is known - : OverrideReturnType // No codegen, let user specify return type + ClientReturn >; /** The cache instance passed in from the `createStorefrontClient` argument. */ cache?: Cache; @@ -202,12 +180,13 @@ type StorefrontHeaders = { purpose: string | null; }; -type StorefrontQueryOptions = StorefrontQuerySecondParam & { +type StorefrontQueryOptions = StorefrontCommonExtraParams & { query: string; mutation?: never; + cache?: CachingStrategy; }; -type StorefrontMutationOptions = StorefrontMutateSecondParam & { +type StorefrontMutationOptions = StorefrontCommonExtraParams & { query?: never; mutation: string; cache?: never; @@ -286,14 +265,17 @@ export function createStorefrontClient( defaultHeaders[STOREFRONT_ACCESS_TOKEN_HEADER], }); - async function fetchStorefrontApi({ + async function fetchStorefrontApi({ query, mutation, variables, cache: cacheOptions, headers = [], storefrontApiVersion, - }: StorefrontQueryOptions | StorefrontMutationOptions): Promise { + }: {variables?: GenericVariables} & ( + | StorefrontQueryOptions + | StorefrontMutationOptions + )): Promise { const userHeaders = headers instanceof Headers ? Object.fromEntries(headers.entries()) @@ -395,12 +377,12 @@ export function createStorefrontClient( * } * ``` */ - query: ((query: string, payload) => { + query(query, options?) { query = minifyQuery(query); assertQuery(query, 'storefront.query'); const result = fetchStorefrontApi({ - ...payload, + ...options, query, }); @@ -409,7 +391,7 @@ export function createStorefrontClient( result.catch(() => {}); return result; - }), + }, /** * Sends a GraphQL mutation to the Storefront API. * @@ -423,12 +405,12 @@ export function createStorefrontClient( * } * ``` */ - mutate: ((mutation: string, payload) => { + mutate(mutation, options?) { mutation = minifyQuery(mutation); assertMutation(mutation, 'storefront.mutate'); const result = fetchStorefrontApi({ - ...payload, + ...options, mutation, }); @@ -437,7 +419,7 @@ export function createStorefrontClient( result.catch(() => {}); return result; - }), + }, cache, CacheNone, CacheLong, diff --git a/packages/hydrogen/tsup.config.ts b/packages/hydrogen/tsup.config.ts index 63997e78c9..b41917fc4a 100644 --- a/packages/hydrogen/tsup.config.ts +++ b/packages/hydrogen/tsup.config.ts @@ -22,7 +22,9 @@ export default [ defineConfig({ ...commonConfig, env: {NODE_ENV: 'production'}, - dts: true, + // Bundle types from hydrogen-codgen so that we + // don't need to add it as a dependency in Hydrogen. + dts: {resolve: ['@shopify/hydrogen-codegen']}, outDir: path.join(outDir, 'production'), minify: true, onSuccess: async () => { diff --git a/tsconfig.json b/tsconfig.json index 596f0267cf..fd0c387f1a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "include": ["**/*.ts"], - "exclude": ["dist", "__tests__", "node_modules"], + "exclude": ["dist", "node_modules", ".turbo", ".cache"], "compilerOptions": { "lib": ["DOM", "DOM.Iterable", "ESNext"], "target": "ESNext",