@konkonam/nuxt-shopify
Version:
Easily integrate shopify with nuxt 3 and 4 🚀
603 lines (577 loc) • 20.2 kB
JavaScript
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 };