UNPKG

@konkonam/nuxt-shopify

Version:

Easily integrate shopify with nuxt 3 and 4 🚀

603 lines (577 loc) • 20.2 kB
import { useLogger, addServerImportsDir, addImportsDir, addServerImports, addImports, addDevServerHandler, addTemplate, addTypeTemplate, updateTemplates, defineNuxtModule, useRuntimeConfig, createResolver, updateRuntimeConfig, addServerHandler } from '@nuxt/kit'; import defu, { defu as defu$1 } from 'defu'; import { kebabCase, upperFirst } from 'scule'; import { joinURL } from 'ufo'; import { existsSync } from 'node:fs'; import { join, basename, dirname } from 'node:path'; import { generate } from '@graphql-codegen/cli'; import { preset, pluckConfig } from '@shopify/graphql-codegen'; import { LogLevels } from 'consola'; import { createClient } from '../dist/runtime/utils/client.js'; import { defineEventHandler, readValidatedBody } from 'h3'; import { z } from 'zod'; import { createStorefrontConfig } from '../dist/runtime/utils/storefront.js'; import { createAdminConfig } from '../dist/runtime/utils/admin.js'; import { readFile } from 'node:fs/promises'; import { minimatch } from 'minimatch'; var ShopifyClientType = /* @__PURE__ */ ((ShopifyClientType2) => { ShopifyClientType2["Storefront"] = "storefront"; ShopifyClientType2["Admin"] = "admin"; return ShopifyClientType2; })(ShopifyClientType || {}); const ignores = [ "!node_modules", "!dist", "!.nuxt", "!.output" ]; const useShopifyConfig = (options) => { const usesClientSide = !!(options.clients?.storefront?.publicAccessToken ?? options.clients?.storefront?.mock); const getClientConfig = (clientType, documents = []) => { const clientOptions = options.clients?.[clientType]; if (!clientOptions) return; if (clientType === "storefront" && clientOptions.mock) { clientOptions.publicAccessToken = "mock-public-access-token"; } clientOptions.sandbox = !!(clientOptions.sandbox === void 0 || clientOptions.sandbox); clientOptions.documents = [ ...clientOptions.documents ?? [], ...documents ]; return clientOptions; }; const buildConfig = () => { const storefront = getClientConfig("storefront" /* Storefront */, [ "**/*.{gql,graphql,ts,js}", "!**/*.admin.{gql,graphql,ts,js}", "!**/admin/**/*.{gql,graphql,ts,js}", ...usesClientSide ? ["**/*.vue"] : [], ...ignores ]); const admin = getClientConfig("admin" /* Admin */, [ "**/*.admin.{gql,graphql,ts,js}", "**/admin/**/*.{gql,graphql,ts,js}", ...ignores ]); return { name: options.name, logger: options.logger, autoImports: options.autoImports, errors: options.errors, clients: { ...storefront && { storefront }, ...admin && { admin } } }; }; const buildPublicConfig = (config2) => { if (!config2.clients?.storefront || !usesClientSide) return void 0; const { privateAccessToken: _privateAccessToken, skipCodegen: _skipCodegen, sandbox: _sandbox, documents: _documents, ...storefront } = config2.clients.storefront; return { name: config2.name, logger: config2.logger, errors: config2.errors, clients: { storefront } }; }; const config = buildConfig(); const publicConfig = buildPublicConfig(config); return { config, publicConfig }; }; let loggerInstance; const useLog = (options) => { if (loggerInstance) return loggerInstance; return loggerInstance = useLogger("shopify", options); }; 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 '@konkonam/nuxt-shopify/${kebabCase(clientType)}' { 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 joinURL("https://shopify.dev", `${clientType}-graphql-direct-proxy`, clientConfig.apiVersion); }; const getTypescriptPluginConfig = (options) => { if (options.clientType !== ShopifyClientType.Storefront) return "typescript"; return { typescript: { useTypeImports: true, defaultScalarType: "unknown", useImplementingTypes: true, enumsAsTypes: true, scalars: { DateTime: "string", Decimal: "string", HTML: "string", URL: "string", Color: "string", UnsignedInt64: "string", ISO8601DateTime: "string", JSON: "string" } } }; }; const generateIntrospection = async (data) => { const config = { schema: getIntrospection(data.options), plugins: [{ introspection: { minify: true } }] }; await data.nuxt.callHook(`${kebabCase(data.options.clientType)}:generate:introspection`, { nuxt: data.nuxt, config }); return extractResult(generate({ overwrite: true, ignoreNoDocuments: true, silent: useLog().level < LogLevels.debug, generates: { [data.options.filename]: config } }, false)); }; const generateTypes = async (data) => { const config = { schema: getIntrospection(data.options), plugins: [getTypescriptPluginConfig(data.options)] }; await data.nuxt.callHook(`${kebabCase(data.options.clientType)}:generate:types`, { nuxt: data.nuxt, config }); return extractResult(generate({ overwrite: true, ignoreNoDocuments: true, silent: useLog().level < LogLevels.debug, 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: `./${kebabCase(data.options.clientType)}.types.d.ts` }, skipTypenameInOperations: true, interfaceExtension: (params) => { return getInterfaceExtensionFunction( data.options.clientType, params.queryType, params.mutationType ); } } }; await data.nuxt.callHook(`${kebabCase(data.options.clientType)}:generate:operations`, { nuxt: data.nuxt, config }); return extractResult(generate({ overwrite: true, silent: useLog().level < LogLevels.debug, generates: { [data.options.filename]: config }, // @ts-expect-error weird behavior pluckConfig }, false)); }; function autoImportDir(path, client) { if (existsSync(path)) { addServerImportsDir(path); if (client) addImportsDir(path); } } function autoImportUtil(name, resolver, client) { const imports = [{ from: resolver.resolve(`./runtime/utils/${name}`), name }]; addServerImports(imports); if (client) addImports(imports); } function registerUtilImports(resolver, client = false) { autoImportUtil("flattenConnection", resolver, client); } function registerAutoImports(nuxt, config, resolver) { const usesClientSide = config.clients.storefront?.mock || (config.clients.storefront?.publicAccessToken?.length ?? 0) > 0; if (config.autoImports?.graphql) { autoImportDir(join(nuxt.options.rootDir, "graphql"), usesClientSide); useLog().debug("Auto-importing GraphQL from `~/graphql` directory"); } if (config.autoImports?.storefront) { autoImportDir(join(nuxt.options.buildDir, "types/storefront"), usesClientSide); useLog().debug("Auto-importing Storefront types"); } if (config.autoImports?.admin) { autoImportDir(join(nuxt.options.buildDir, "types/admin"), usesClientSide); useLog().debug("Auto-importing Admin types"); } registerUtilImports(resolver, usesClientSide); } function getSandboxTemplate(clientType) { return ` <!-- * Copyright (c) 2025 GraphQL Contributors * All rights reserved. --> <!doctype html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>GraphiQL - ${clientType}</title> <style> body { margin: 0; } #graphiql { height: 100dvh; } .loading { height: 100%; display: flex; align-items: center; justify-content: center; font-size: 4rem; } </style> <link rel="stylesheet" href="https://esm.sh/graphiql/dist/style.css" /> <link rel="stylesheet" href="https://esm.sh/@graphiql/plugin-explorer/dist/style.css" /> <script type="importmap"> { "imports": { "react": "https://esm.sh/react@19.1.0", "react/": "https://esm.sh/react@19.1.0/", "react-dom": "https://esm.sh/react-dom@19.1.0", "react-dom/": "https://esm.sh/react-dom@19.1.0/", "graphiql": "https://esm.sh/graphiql?standalone&external=react,react-dom,@graphiql/react,graphql", "graphiql/": "https://esm.sh/graphiql/", "@graphiql/plugin-explorer": "https://esm.sh/@graphiql/plugin-explorer?standalone&external=react,@graphiql/react,graphql", "@graphiql/react": "https://esm.sh/@graphiql/react?standalone&external=react,react-dom,graphql,@graphiql/toolkit,@emotion/is-prop-valid", "@graphiql/toolkit": "https://esm.sh/@graphiql/toolkit?standalone&external=graphql", "graphql": "https://esm.sh/graphql@16.11.0", "@emotion/is-prop-valid": "data:text/javascript," } } <\/script> <script type="module"> import React from 'react'; import ReactDOM from 'react-dom/client'; import { GraphiQL, HISTORY_PLUGIN } from 'graphiql'; import { createGraphiQLFetcher } from '@graphiql/toolkit'; import { explorerPlugin } from '@graphiql/plugin-explorer'; import 'graphiql/setup-workers/esm.sh'; const fetcher = createGraphiQLFetcher({ url: '/_sandbox/proxy/${clientType}', }); const plugins = [HISTORY_PLUGIN, explorerPlugin()]; function App() { return React.createElement(GraphiQL, { fetcher, plugins, defaultEditorToolsVisibility: true, }); } const container = document.getElementById('graphiql'); const root = ReactDOM.createRoot(container); root.render(React.createElement(App)); <\/script> </head> <body> <div id="graphiql"> <div class="loading">Loading\u2026</div> </div> </body> </html> `; } function getSandboxUrl(nuxt, clientType) { const url = new URL(nuxt.options.devServer.url); return url.href + "_sandbox/" + clientType; } 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.string(), z.unknown()).optional() }); const body = await readValidatedBody(event, schema.parse); let client; switch (clientType) { case "storefront": client = createClient(createStorefrontConfig(config)); break; case "admin": client = createClient(createAdminConfig(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); } const indexTemplate = (types, operations) => ` export * from './${basename(types)}' export * from './${basename(operations)}' `; 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: () => indexTemplate(types.filename, operations.filename).trimStart() }); nuxt.options = defu(nuxt.options, { alias: { [`#shopify/${clientType}`]: `./${dirname(index.filename)}` }, nitro: { typescript: { tsConfig: { include: [ `./${dirname(index.filename)}` ] } } } }); } const useShopifyConfigValidation = (options) => { const clientSchema = z.object({ apiVersion: z.string().min(1), sandbox: z.boolean().optional(), documents: z.array(z.string().min(1)).optional(), headers: z.record(z.string().or(z.array(z.string().min(1)))).optional(), retries: z.number().optional(), skipCodegen: z.boolean().optional() }); const schema = z.object({ name: z.string().min(1), logger: z.any().optional(), autoImports: z.object({ graphql: z.boolean().optional().default(true), storefront: z.boolean().optional().default(true), admin: z.boolean().optional().default(true) }).optional().default({ graphql: true, storefront: true, admin: true }), errors: z.object({ throw: z.boolean().optional().default(true) }).optional().default({ throw: true }), clients: z.object({ storefront: clientSchema.extend({ publicAccessToken: z.string().min(1).optional(), privateAccessToken: z.string().min(1).optional(), proxy: z.boolean().optional().default(true).or(z.string().optional()), mock: z.boolean().optional() }).optional(), admin: clientSchema.extend({ accessToken: z.string().min(1) }).optional() }) }); return schema.safeParse(options); }; const module = defineNuxtModule({ meta: { name: "nuxt-shopify", configKey: "shopify", compatibility: { nuxt: ">=3.0.0" } }, async setup(options, nuxt) { const runtimeConfig = useRuntimeConfig(); const log = useLog(options.logger); const moduleOptions = useShopifyConfigValidation(defu$1( runtimeConfig.public.shopify, runtimeConfig.shopify, options )); const resolver = createResolver(import.meta.url); if (moduleOptions.success) { log.start("Starting setup"); const { config, publicConfig } = useShopifyConfig(moduleOptions.data); for (const _clientType in config.clients) { const clientType = _clientType; const clientConfig = config.clients[clientType]; if (!clientConfig) continue; setupClient(nuxt, config, clientType, clientConfig); } await nuxt.callHook("shopify:config", { nuxt, config }); updateRuntimeConfig({ _shopify: config, public: { _shopify: publicConfig } }); registerAutoImports(nuxt, config, resolver); log.success("Finished setup"); } else { log.info("Skipping setup: config not provided or invalid"); log.info("See module configuration reference: https://konkonam.github.io/nuxt-shopify/configuration/module"); log.debug(`Error while parsing module options: ${moduleOptions.error}`); } } }); const setupClient = (nuxt, config, clientType, clientConfig) => { const log = useLog(config.logger); const resolver = createResolver(import.meta.url); 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}`); } addServerImports([{ from: resolver.resolve(`./runtime/server/utils/${clientType}`), name: `use${upperFirst(clientType)}` }]); if (clientType === "storefront") { setupStorefrontFeatures(nuxt, config, clientConfig); } }; const setupStorefrontFeatures = (nuxt, config, clientConfig) => { const log = useLog(config.logger); const resolver = createResolver(import.meta.url); if (clientConfig.publicAccessToken?.length || clientConfig.mock) { addImports([ { from: resolver.resolve(`./runtime/composables/storefront`), name: "useStorefront" }, { from: resolver.resolve(`./runtime/composables/async`), name: "useAsyncStorefront" } ]); if (clientConfig.proxy) { if (!nuxt.options.ssr) { log.info("Server-side request proxying is only available in SSR mode, skipping proxy setup."); } const url = typeof clientConfig.proxy === "string" ? clientConfig.proxy : "/_proxy/storefront"; addServerHandler({ handler: resolver.resolve(`./runtime/server/api/proxy/storefront`), route: url }); if (nuxt.options.dev) { log.info(`Storefront proxy available at: ${joinURL(nuxt.options.devServer.url, url)}`); } } } }; export { module as default, setupClient, setupStorefrontFeatures };