UNPKG

@konkonam/nuxt-shopify

Version:

Easily integrate shopify into your nuxt 3 & 4 project 🚀

423 lines (407 loc) • 13.7 kB
import { useLogger, addDevServerHandler, addTemplate, addTypeTemplate, updateTemplates, defineNuxtModule, createResolver, addServerImports, updateRuntimeConfig } from '@nuxt/kit'; import { upperFirst } from 'scule'; import { generate } from '@graphql-codegen/cli'; import { preset, pluckConfig } from '@shopify/graphql-codegen'; import { existsSync } from 'node:fs'; import { join, basename, dirname } from 'node:path'; import { z } from 'zod'; import { createAdminApiClient } from '@shopify/admin-api-client'; import { createStorefrontApiClient } from '@shopify/storefront-api-client'; import { defineEventHandler, readValidatedBody } from 'h3'; import defu from 'defu'; import { minimatch } from 'minimatch'; import { readFile } from 'node:fs/promises'; let loggerInstance; const useLog = (options) => { if (loggerInstance) return loggerInstance; return loggerInstance = useLogger("nuxt-shopify", { ...options, defaults: { message: "[shopify]" } }); }; async function extractResult(input) { try { return (await input)?.at(0)?.content ?? ""; } catch (error) { useLog().error(error.message); return ""; } } const getInterfaceExtensionFunction = (clientType, queryType, mutationType) => ` declare module '@shopify/${clientType}-api-client' { type InputMaybe<T> = ${upperFirst(clientType)}Types.InputMaybe<T> interface ${upperFirst(clientType)}Queries extends ${queryType} {} interface ${upperFirst(clientType)}Mutations extends ${mutationType} {} } `; const getIntrospection = (options) => { const { clientType, clientConfig, introspection } = options; if (introspection && existsSync(introspection)) { return introspection; } return `https://shopify.dev/${clientType}-graphql-direct-proxy/${clientConfig.apiVersion}/`; }; const generateIntrospection = async (data) => { const config = { schema: getIntrospection(data.options), plugins: ["introspection"] }; await data.nuxt.callHook(`${data.options.clientType}:generate:introspection`, { nuxt: data.nuxt, config }); return extractResult(generate({ overwrite: true, ignoreNoDocuments: true, silent: true, generates: { [data.options.filename]: config } }, false)); }; const generateTypes = async (data) => { const config = { schema: getIntrospection(data.options), plugins: ["typescript"] }; await data.nuxt.callHook(`${data.options.clientType}:generate:types`, { nuxt: data.nuxt, config }); return extractResult(generate({ overwrite: true, ignoreNoDocuments: true, silent: true, generates: { [data.options.filename]: config } }, false)); }; const generateOperations = async (data) => { const config = { schema: getIntrospection(data.options), preset, documents: data.options.clientConfig.documents?.map((d) => { if (d.startsWith("!")) { return "!" + join(data.nuxt.options.rootDir, d.replace("!", "")); } return join(data.nuxt.options.rootDir, d); }), presetConfig: { importTypes: { namespace: `${upperFirst(data.options.clientType)}Types`, from: `./${data.options.clientType}.types.d.ts` }, skipTypenameInOperations: true, interfaceExtension: (params) => { return getInterfaceExtensionFunction( data.options.clientType, params.queryType, params.mutationType ); } } }; await data.nuxt.callHook(`${data.options.clientType}:generate:operations`, { nuxt: data.nuxt, config }); return extractResult(generate({ overwrite: true, silent: true, // @ts-expect-error weird behavior pluckConfig, generates: { [data.options.filename]: config } }, false)); }; const ignores = [ "!node_modules", "!.output", "!.nuxt" ]; const useShopifyConfig = (options) => { const getClientConfig = (clientType, documents = []) => { const clientOptions = options.clients?.[clientType]; if (!clientOptions) return; clientOptions.storeDomain = `https://${options.name}.myshopify.com`; clientOptions.sandbox = !!(clientOptions.sandbox === void 0 || clientOptions.sandbox); clientOptions.documents = [ ...clientOptions.documents ?? [], ...documents ]; return clientOptions; }; const storefront = getClientConfig("storefront" /* Storefront */, [ "**/*.{gql,graphql,ts,js}", "!**/*.admin.{gql,graphql,ts,js}", ...ignores ]); const admin = getClientConfig("admin" /* Admin */, [ "**/*.admin.{gql,graphql,ts,js}", ...ignores ]); return { name: options.name, logger: options.logger, clients: { ...storefront && { storefront }, ...admin && { admin } } }; }; const useShopifyConfigSchema = (options) => { const clientSchema = z.object({ apiVersion: z.string().min(1), sandbox: z.boolean().optional(), documents: z.array(z.string()).optional() }); const schema = z.object({ name: z.string().min(1), clients: z.object({ storefront: clientSchema.extend({ publicAccessToken: z.string().min(1).optional(), privateAccessToken: z.string().min(1).optional() }).optional(), admin: clientSchema.extend({ accessToken: z.string().min(1) }).optional() }) }); return schema.safeParse(options); }; function getSandboxTemplate(clientType) { return ` <!doctype html> <html lang="en"> <head> <title>GraphiQL - ${clientType}</title> <style> body { height: 100%; margin: 0; width: 100%; overflow: hidden; } #graphiql { height: 100vh; } </style> <script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin><\/script> <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin><\/script> <script src="https://unpkg.com/graphiql/graphiql.min.js" type="application/javascript"><\/script> <script src="https://unpkg.com/@graphiql/plugin-explorer/dist/index.umd.js" crossorigin><\/script> <link href="https://unpkg.com/graphiql/graphiql.min.css" rel="stylesheet"/> <link href="https://unpkg.com/@graphiql/plugin-explorer/dist/style.css" rel="stylesheet"/> </head> <body> <div id="graphiql">Loading...</div> <script> const root = ReactDOM.createRoot(document.getElementById('graphiql')); const explorerPlugin = GraphiQLPluginExplorer.explorerPlugin(); const fetcher = async (graphQLParams, opts) => { const headers = opts?.headers || {}; const response = await fetch('/_sandbox/proxy/${clientType}', { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify(graphQLParams), }); if (!response.ok) { throw new Error('Failed to fetch data'); } return response.json(); }; root.render( React.createElement(GraphiQL, { fetcher, defaultEditorToolsVisibility: true, plugins: [explorerPlugin], }), ); <\/script> </body> </html> `; } function getSandboxUrl(nuxt, clientType) { const url = new URL(nuxt.options.devServer.url); return url.href + "_sandbox/" + clientType; } function getClientConfig(clientType, config) { const clientConfig = config?.clients?.[clientType]; if (!clientConfig) throw new Error(`Could not create ${clientType} client`); const { skipCodegen: _skipCodegen, sandbox: _sandbox, documents: _documents, ...options } = clientConfig; return options; } function getSandboxHandler(clientType) { return defineEventHandler(async (event) => { event.headers.set("content-type", "text/html"); return getSandboxTemplate(clientType); }); } function getSandboxProxyHandler(nuxt, clientType) { return defineEventHandler(async (event) => { const config = nuxt.options.runtimeConfig._shopify; const schema = z.object({ query: z.string(), variables: z.record(z.unknown()).optional() }); const body = await readValidatedBody(event, schema.parse); let client; switch (clientType) { case "storefront": client = createStorefrontApiClient(getClientConfig(clientType, config)); break; case "admin": client = createAdminApiClient(getClientConfig(clientType, config)); break; default: throw new Error("The requested client is not supported"); } return client.request(body.query, { variables: body.variables }); }); } function installSandbox(nuxt, clientType) { addDevServerHandler({ handler: getSandboxHandler(clientType), route: `/_sandbox/${clientType}` }); addDevServerHandler({ handler: getSandboxProxyHandler(nuxt, clientType), route: `/_sandbox/proxy/${clientType}` }); return getSandboxUrl(nuxt, clientType); } function setupWatcher(nuxt, template) { nuxt.hook("builder:watch", async (_event, file) => { for (const document of template.options?.clientConfig?.documents ?? []) { if (!minimatch(file, document)) continue; const content = await readFile(join(nuxt.options.srcDir, file), "utf8").catch(() => ""); if (file.endsWith(".gql") || file.endsWith(".graphql") || content.includes("#graphql") || content.includes("/* GraphQL */")) { return updateTemplates({ filter: (t) => t.filename === template.options?.filename }); } } }); } function registerTemplates(nuxt, clientType, clientConfig) { const introspectionFilename = `schema/${clientType}.schema.json`; const introspectionPath = join(nuxt.options.buildDir, introspectionFilename); const introspection = addTemplate({ filename: introspectionFilename, getContents: generateIntrospection, options: { filename: introspectionFilename, clientType, clientConfig, introspection: introspectionPath }, write: true }); const typesFilename = `types/${clientType}/${clientType}.types`; const types = addTypeTemplate({ filename: `${typesFilename}.d.ts`, getContents: generateTypes, options: { filename: `${typesFilename}.d.ts`, clientType, clientConfig, introspection: introspection.dst } }); const operationsFilename = `types/${clientType}/${clientType}.operations`; const operations = addTypeTemplate({ filename: `${operationsFilename}.d.ts`, getContents: generateOperations, options: { clientType, clientConfig, filename: `${operationsFilename}.d.ts`, introspection: introspection.dst } }); setupWatcher(nuxt, operations); const index = addTypeTemplate({ filename: `types/${clientType}/index.d.ts`, getContents: () => `export * from './${basename(types.filename)}' export * from './${basename(operations.filename)}' ` }); nuxt.options = defu(nuxt.options, { alias: { [`#shopify/${clientType}`]: dirname(index.filename) }, nitro: { typescript: { tsConfig: { include: [index.filename] } } } }); } const module = defineNuxtModule({ meta: { name: "nuxt-shopify", configKey: "shopify", compatibility: { nuxt: ">=3.0.0" } }, async setup(options, nuxt) { const log = useLog(options.logger); const moduleOptions = useShopifyConfigSchema(options); if (!moduleOptions.success) { log.info("Skipping setup: config not provided or invalid"); log.debug(`See module configuration reference: https://konkonam.github.io/nuxt-shopify/configuration/module`); log.debug(`Error while parsing module options: ${moduleOptions.error}`); } else { log.start("Starting setup"); const resolver = createResolver(import.meta.url); const config = useShopifyConfig(moduleOptions.data); for (const _clientType in config.clients) { const clientType = _clientType; const clientConfig = config.clients[clientType]; if (!clientConfig) continue; if (!clientConfig.skipCodegen) { registerTemplates(nuxt, clientType, clientConfig); } else { log.info(`Skipping type generation for ${clientType}`); } if (nuxt.options.dev && clientConfig.sandbox) { const url = installSandbox(nuxt, clientType); log.info(`${upperFirst(clientType)} sandbox available at: ${url}`); } const functionName = `use${upperFirst(clientType)}`; addServerImports([{ from: resolver.resolve(`./runtime/server/utils/${functionName}`), name: functionName }]); } await nuxt.callHook("shopify:config", { nuxt, config }); updateRuntimeConfig({ _shopify: config }); log.success("Finished setup"); } } }); export { module as default };