@konkonam/nuxt-shopify
Version:
Easily integrate shopify into your nuxt 3 & 4 project 🚀
423 lines (407 loc) • 13.7 kB
JavaScript
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 };