UNPKG

@scayle/storefront-nuxt

Version:

Nuxt integration for the SCAYLE Commerce Engine and Storefront API

606 lines (595 loc) 20.9 kB
import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { defineNuxtModule, createResolver, addServerTemplate, addTypeTemplate, extendViteConfig, addTemplate, updateTemplates, addPlugin, addImportsDir, runWithNuxtContext, resolvePath } from '@nuxt/kit'; import { rpcMethods } from '@scayle/storefront-core'; import { defu } from 'defu'; import { genExport, genImport, genSafeVariableName } from 'knitwork'; import { builtinDrivers } from 'unstorage'; import { createConsola } from 'consola'; import { nodeFileTrace } from '@vercel/nft'; import { createJiti } from 'jiti'; import { getApiBasePath } from '../dist/runtime/server/middleware/bootstrap-utils.js'; export * from '../dist/runtime/types/module.js'; function stringToBoolean(value, defaultValue = false) { return value ? value.toLowerCase() === "true" : defaultValue; } function getUsedDrivers(options) { const drivers = /* @__PURE__ */ new Set(); drivers.add("memory"); const shops = options.shops ?? {}; const addDriver = (config) => { if (config?.cache?.driver) { drivers.add(config.cache.driver); } if (config?.session?.driver) { drivers.add(config.session.driver); } }; addDriver(options.storage); Object.values(shops).forEach((shop) => { addDriver(shop.storage); }); return Array.from(drivers); } function createVirtualDriverImport(usedDrivers) { const usesCompression = !stringToBoolean( process.env.SFC_OMIT_COMPRESSION, false ); const isScaleKvUsed = usedDrivers.includes("scayleKv"); const isUnstorageDriver = (driver) => driver in builtinDrivers; return `${usedDrivers.filter(isUnstorageDriver).map( (driver) => genImport(builtinDrivers[driver], genSafeVariableName(driver)) ).join("\n")} ${isScaleKvUsed ? genImport("@scayle/unstorage-scayle-kv-driver", "scayleKv") : ""} ${usesCompression ? genImport("@scayle/unstorage-compression-driver", "compression") : ""} export default { ${usedDrivers.map((driver) => `"${driver}":${genSafeVariableName(driver)},`).join("\n")} ${isScaleKvUsed ? "scayleKv," : ""} ${usesCompression ? "compression" : ""} }`; } const PACKAGE_NAME = "@scayle/storefront-nuxt"; const PACKAGE_VERSION = "8.61.2"; const logger = createConsola({ fancy: true, formatOptions: { colors: true }, level: 1, defaults: { tag: PACKAGE_NAME } // At runtime consola/basic will be loaded which supports the fancy option // However when typechecking it will resolve to consola/browser which does have the fancy option // This assertion avoids the typescript error }); function createRpcMethodIndex(customRpcImports) { return `${customRpcImports.map(({ source, names }) => genExport(source, names)).join("\n")} export {}`; } function createRpcMethodTypeDeclaration(customRpcImports) { return `// Auto-generated type RpcMethodsCustomType = { ${customRpcImports.reduce( (lines, { source, names }) => lines.concat( names.map( (name) => `${name}: typeof import('${source}')['${name}'],` ).join("\n") ), [] ).join("\n")} } declare module '@scayle/storefront-nuxt' { export interface RpcMethodsStorefront extends RpcMethodsCustomType {} } export {}`; } const DEFAULT_RPC_HTTP_METHOD = "POST"; const ALLOWED_RPC_HTTP_METHODS = /* @__PURE__ */ new Set([ "GET", "POST", "PUT", "DELETE" ]); function readHttpMethod(handler) { if (handler && typeof handler === "function" && "httpMethod" in handler && typeof handler.httpMethod === "string") { const method = handler.httpMethod.toUpperCase(); if (ALLOWED_RPC_HTTP_METHODS.has(method)) { return method; } } return DEFAULT_RPC_HTTP_METHOD; } function getNuxtStringAliases(nuxt) { const out = {}; for (const [specifier, target] of Object.entries(nuxt.options.alias)) { if (typeof target === "string") { out[specifier] = target; } } return out; } function sortAliasRecordByLongestSpecifierFirst(map) { return Object.fromEntries( Object.entries(map).sort((a, b) => b[0].length - a[0].length) ); } async function resolveAliasChain(target, resolveOpts) { let current = target; for (let i = 0; i < 10; i++) { const next = await resolvePath(current, resolveOpts); if (next === current) { return next; } current = next; } logger.warn(`Alias chain for ${target} resolved to ${current} but maximum depth of 10 was reached`); return current; } async function buildResolvedNuxtAliasMap(nuxt) { return runWithNuxtContext(nuxt, async () => { const alias = getNuxtStringAliases(nuxt); const resolveOpts = { cwd: nuxt.options.rootDir, alias, virtual: true, fallbackToOriginal: true }; const resolved = {}; for (const [specifier, target] of Object.entries(alias)) { try { resolved[specifier] = await resolveAliasChain(target, resolveOpts); } catch { resolved[specifier] = target; } } return sortAliasRecordByLongestSpecifierFirst(resolved); }); } function getJitiParentUrlForNuxtApp(nuxt) { const pkg = join(nuxt.options.rootDir, "package.json"); if (existsSync(pkg)) { return pathToFileURL(pkg).href; } return pathToFileURL(join(nuxt.options.rootDir, ".")).href; } async function buildRpcHttpMethodManifest(customRpcImports, coreHandlers, nuxt) { const manifest = {}; for (const [name, handler] of Object.entries(coreHandlers)) { manifest[name] = readHttpMethod(handler); } if (customRpcImports.length === 0) { return manifest; } const jiti = createJiti(getJitiParentUrlForNuxtApp(nuxt), { alias: await buildResolvedNuxtAliasMap(nuxt) }); for (const { source, names } of customRpcImports) { let loadedModule; try { loadedModule = await jiti.import(source); } catch (error) { logger.warn( `Failed to load custom RPC module '${source}' while inferring HTTP methods. Methods from this module will default to '${DEFAULT_RPC_HTTP_METHOD}'.`, error ); } for (const name of names) { manifest[name] = loadedModule && name in loadedModule ? readHttpMethod(loadedModule[name]) : DEFAULT_RPC_HTTP_METHOD; } } return manifest; } function createRpcHttpMethodsSource(manifest) { return `// Auto-generated by @scayle/storefront-nuxt export const rpcHttpMethods = ${JSON.stringify(manifest, null, 2)} `; } async function nitroSetup(nitroConfig, config, moduleOptions, nuxt) { const { resolve, resolvePath: resolvePath2 } = createResolver(import.meta.url); const { apiBasePath, customRpcImports, coreMethodNames, usedDrivers, rpcHttpMethodManifest, rpcHttpMethodsPath } = config; const nuxtAliases = await buildResolvedNuxtAliasMap(nuxt); nitroConfig.replace = nitroConfig.replace || {}; nitroConfig.replace["process.env.SFC_OMIT_MD5"] = stringToBoolean( process.env.SFC_OMIT_MD5 ); nitroConfig.replace.__sfc_version = PACKAGE_VERSION; nitroConfig.alias = { ...nuxtAliases, ...nitroConfig.alias ?? {}, "#virtual/rpcHttpMethods": rpcHttpMethodsPath }; nitroConfig.virtual = { ...nitroConfig.virtual, "#virtual/storage-drivers": createVirtualDriverImport(usedDrivers), "#virtual/customRpcMethods": createRpcMethodIndex(customRpcImports) }; nitroConfig.handlers = nitroConfig.handlers ?? []; nitroConfig.handlers.unshift({ middleware: true, handler: resolve("./runtime/server/middleware/bootstrap") }); const allRpcNames = customRpcImports.reduce((acc, { names }) => acc.concat(names), []).concat(coreMethodNames); for (const methodName of allRpcNames) { const httpMethod = rpcHttpMethodManifest[methodName] ?? DEFAULT_RPC_HTTP_METHOD; nitroConfig.handlers.push({ route: `${apiBasePath}/rpc/${methodName}`, handler: resolve("./runtime/api/rpcHandler"), method: httpMethod.toLowerCase() }); } nitroConfig.handlers.push({ route: `${apiBasePath}/purge/all`, handler: resolve("./runtime/api/purgeAll"), method: "post" }); nitroConfig.handlers.push({ route: `${apiBasePath}/purge/tags`, handler: resolve("./runtime/api/purgeTags"), method: "post" }); nitroConfig.handlers.push({ route: `${apiBasePath}/up`, handler: resolve("./runtime/api/up") }); nitroConfig.plugins = nitroConfig.plugins ?? []; nitroConfig.plugins.push(resolve("./runtime/nitro/plugins/nitroLogger")); nitroConfig.plugins.push(resolve("./runtime/nitro/plugins/redirectOnError")); if (!!moduleOptions?.storage || Object.values(moduleOptions?.shops || {}).some( (shopConfig) => !!shopConfig.storage )) { nitroConfig.plugins.push( resolve("./runtime/nitro/plugins/nitroLegacyStorageConfig") ); } nitroConfig.plugins.push(resolve("./runtime/nitro/plugins/internalFetch")); if (stringToBoolean(process.env.SFC_PLUGIN_CONFIG_VALIDATION_ENABLED, true)) { logger.debug("Installing config validation plugin"); nitroConfig.plugins.push( resolve("./runtime/nitro/plugins/configValidation") ); } if (stringToBoolean( process.env.SFC_PLUGIN_DOMAIN_CONFIG_VALIDATION_ENABLED, true ) && moduleOptions.shopSelector === "domain") { nitroConfig.plugins.push( resolve("./runtime/nitro/plugins/validateDomainConfig") ); } if (stringToBoolean(process.env.SFC_PLUGIN_POWERED_BY_ENABLED, true)) { logger.debug("Installing X-Powered-By plugin"); nitroConfig.plugins.push( resolve("./runtime/nitro/plugins/nitroSetXPoweredByHeader") ); } if (stringToBoolean(process.env.SFC_PLUGIN_RUNTIME_PERFORMANCE_ENABLED, true)) { logger.debug("Installing runtime performance plugin"); nitroConfig.plugins.push( resolve("./runtime/nitro/plugins/cacheRuntimeConfig") ); } const base = resolve(`./`); const handlerPaths = nitroConfig.handlers.filter((h) => h?.handler && h?.handler.startsWith(base)).map((h) => h?.handler); const pluginPaths = nitroConfig.plugins.filter( (p) => p && p?.startsWith(base) ); const runtimeEntries = await Promise.all( [...handlerPaths, ...pluginPaths].map((p) => resolvePath2(p)) ); const trace = await nodeFileTrace(runtimeEntries, { base }); const tracedImports = [...trace.fileList].map((p) => resolve(p)); nitroConfig.externals = nitroConfig.externals ?? {}; nitroConfig.externals.inline = nitroConfig.externals.inline ?? []; nitroConfig.externals.inline.push(...tracedImports); } async function nitroPostInit(moduleOptions, nitroApp) { const { resolve } = createResolver(import.meta.url); if (!moduleOptions?.storage && !Object.values(moduleOptions?.shops || {}).some( (shopConfig) => !!shopConfig.storage )) { nitroApp.options.plugins.push( resolve("./runtime/nitro/plugins/nitroStorageConfig") ); } } function validateRpcMethodOverrides(rpcMethodOverrides, customRpcNames, coreRpcNames) { const overriddenRPCMethodNames = new Set(rpcMethodOverrides); for (const rpcMethodName of customRpcNames) { if (coreRpcNames.has(rpcMethodName) && !overriddenRPCMethodNames.has(rpcMethodName)) { logger.warn(` Overriding RPC method '${rpcMethodName}' from '@scayle/storefront-nuxt' with a local custom implementation. Should this be done on purpose, please be aware that this can lead to unexpected issues since the '@scayle/storefront-nuxt' package also uses this RPC method internally. To silence this warning, you can add '${rpcMethodName}' to 'rpcMethodOverrides' in the '@scayle/storefront-nuxt' config. In the future this will become an error where overrides need to be explicitly defined as an override. `); } else { overriddenRPCMethodNames.delete(rpcMethodName); } } if (overriddenRPCMethodNames.size > 0) { logger.warn(` Detected RPC methods which were supposed to be overridden but are not. This indicates an incorrect configuration and should be adjusted. Check the 'rpcMethodOverrides' property. Missing RPC Method Overrides: ${Array.from(overriddenRPCMethodNames).map((name) => `'${name}'`).join(", ")} `); } } const module$1 = defineNuxtModule({ meta: { name: PACKAGE_NAME, version: PACKAGE_VERSION, configKey: "storefront", compatibility: { nuxt: ">=3.13" } }, // Default configuration options of the Nuxt module defaults: { rpcDir: void 0, rpcMethodNames: void 0, rpcMethodOverrides: void 0, apiBasePath: "/api" }, async setup(options, nuxt) { const { resolve } = createResolver(import.meta.url); const { resolve: appResolve, resolvePath: resolvePath2 } = createResolver( nuxt.options.rootDir ); addServerTemplate({ filename: "#internal/storefront-options.mjs", getContents: () => ` export const apiBasePath = '${options.apiBasePath}' export const rpcMethodNames = ${options.rpcMethodNames ? JSON.stringify(options.rpcMethodNames, null, 2) : "undefined"} export const rpcMethodOverrides = ${options.rpcMethodOverrides ? JSON.stringify(options.rpcMethodOverrides, null, 2) : "undefined"} ` }); addTypeTemplate({ filename: "types/storefront-options.d.ts", getContents: () => ` declare module '#internal/storefront-options.mjs' { export const apiBasePath: string export const rpcMethodNames: string[] | undefined export const rpcMethodOverrides: string[] | undefined } ` }); const runtimeConfigDefaults = { // TODO: Nuxt is making us give a default for each options // How do we mark some as required? session: { cookieName: "$session", sameSite: "lax" // TODO: If we give a default value here, nuxt will generate the runtime type incorrectly // secret needs to be typed as string | string[] // secret: 'current-secret', // if we set maxAge explicitly to undefined `useRuntimeConfig` will set it default to '' which leads to no sessions not being saved // maxAge: undefined, }, shopSelector: "domain", oauth: { apiHost: "", clientId: "", clientSecret: "" }, idp: { enabled: false, idpKeys: [], idpRedirectURL: "" }, appKeys: { wishlistKey: "wishlist_{shopId}_{userId}", basketKey: "basket_{shopId}_{userId}", hashAlgorithm: "sha256" }, legacy: { enableSessionMigration: false }, internalAccessHeader: "", shops: {}, sapi: { host: "", token: "" } }; nuxt.options.runtimeConfig.storefront = defu( nuxt.options.runtimeConfig.storefront, runtimeConfigDefaults ); nuxt.options.runtimeConfig.public.storefront = defu(nuxt.options.runtimeConfig.public.storefront, { log: { name: "storefront-nuxt", level: "info", json: false, output: "auto" } }); extendViteConfig((config) => { config.optimizeDeps = config.optimizeDeps || {}; config.optimizeDeps.include = config.optimizeDeps.include || []; config.optimizeDeps.include.push( PACKAGE_NAME, "@scayle/storefront-core", "@scayle/storefront-api", "slugify" ); }); const apiBasePath = getApiBasePath(options, "/"); const rpcMethodNames = new Set(Object.keys(rpcMethods)); const storefrontRpcMethodNames = options.rpcMethodNames ?? []; const customRpcImports = []; let rpcHttpMethodManifest = {}; let rpcHttpMethodsPath = ""; nuxt.hook("modules:done", async () => { await nuxt.callHook("storefront:custom-rpc:extend", customRpcImports); customRpcImports.forEach((customImport) => { customImport.names.forEach((name) => { if (rpcMethodNames.has(name)) { throw new Error( `Core RPC method '${name}' cannot be overridden via the 'storefront:custom-rpc:extend' hook` ); } }); customImport.names = customImport.names.filter( (name) => !storefrontRpcMethodNames.includes(name) ); }); const rpcDir = await resolvePath2( appResolve(options.rpcDir ?? "./rpcMethods") ); if (existsSync(rpcDir)) { customRpcImports.push({ source: rpcDir, names: storefrontRpcMethodNames }); } validateRpcMethodOverrides( options.rpcMethodOverrides ?? [], customRpcImports.reduce( (acc, { names }) => acc.concat(names), [] ), rpcMethodNames ); rpcHttpMethodManifest = await buildRpcHttpMethodManifest( customRpcImports, rpcMethods, nuxt ); const rpcHttpMethodsSource = createRpcHttpMethodsSource( rpcHttpMethodManifest ); const rpcHttpMethodsTemplate = addTemplate({ filename: "rpc-http-methods.mjs", write: true, getContents: () => rpcHttpMethodsSource }); rpcHttpMethodsPath = rpcHttpMethodsTemplate.dst; nuxt.options.alias["#virtual/rpcHttpMethods"] = rpcHttpMethodsPath; await updateTemplates({ filter: (template) => template.filename === "rpc-http-methods.mjs" }); addTypeTemplate({ filename: "types/storefront-custom-rpc.d.ts", getContents: () => createRpcMethodTypeDeclaration(customRpcImports) }); }); addTypeTemplate({ filename: "types/storefront-bootstrap.d.ts", getContents: () => `// Auto-generated import type { RuntimeConfig } from '@nuxt/schema' import type { RpcContext, Cache, Log } from '@scayle/storefront-core' import type { PublicShopConfig } from '@scayle/storefront-nuxt'; declare module 'h3' { interface H3EventContext { $rpcContext?: RpcContext, $cache?: Cache $currentShop?: PublicShopConfig, $availableShops?: PublicShopConfig[] $log: Log } } declare module '@scayle/storefront-core' { export interface RuntimeConfiguration extends RuntimeConfig {} } export {}` }); nuxt.hooks.hook("nitro:config", async (nitroConfig) => { await nitroSetup( nitroConfig, { apiBasePath, customRpcImports, coreMethodNames: [...rpcMethodNames], usedDrivers: getUsedDrivers(nuxt.options.runtimeConfig.storefront), rpcHttpMethodManifest, rpcHttpMethodsPath }, nuxt.options.runtimeConfig.storefront, nuxt ); }); nuxt.hooks.hook("nitro:init", async (nitro) => { await nitroPostInit(nuxt.options.runtimeConfig.storefront, nitro); nitro.hooks.hook("rollup:before", (_nitro, rollupConfig) => { const existingHandler = rollupConfig.onwarn; rollupConfig.onwarn = (warning, handler) => { if (warning.code === "THIS_IS_UNDEFINED" && warning.loc?.file?.includes("@opentelemetry")) { return; } if (existingHandler) { existingHandler(warning, handler); } else { handler(warning); } }; }); }); nuxt.options.alias["#storefront/composables"] = resolve( "./runtime/composables" ); nuxt.options.build.transpile.push("#storefront/composables"); addPlugin(resolve("./runtime/plugin/shop")); addPlugin({ src: resolve("./runtime/plugin/log.client"), mode: "client" }); addPlugin({ src: resolve("./runtime/plugin/log.server"), mode: "server" }); addImportsDir(resolve("runtime/composables/storefront")); addImportsDir(resolve("runtime/composables/core")); addImportsDir(resolve("runtime/composables")); addImportsDir(resolve("runtime/utils")); const keyedComposables = [ "useBrand", "useBrands", "useCategories", "useCategoryById", "useCategoryByPath", "useCurrentPromotions", "useFacet", "useFilters", "useIDP", "useNavigationTree", "useNavigationTreeById", "useNavigationTreeByName", "useNavigationTrees", "useOrder", "useOrderConfirmation", "useProduct", "useProducts", "useProductsByIds", "useProductsByReferenceKeys", "useProductsCount", "usePromotions", "usePromotionsByIds", "useShopConfiguration", "useUserAddresses", "useVariant" ]; nuxt.options.optimization.keyedComposables.push( ...keyedComposables.map((name) => ({ name, source: "@scayle/storefront-nuxt", argumentLength: 2 })) ); nuxt.options.build.transpile = [ ...nuxt.options.build?.transpile || [], PACKAGE_NAME, "@scayle/storefront-core", "crypto-js" ]; } }); export { module$1 as default };