@scayle/storefront-nuxt
Version:
Nuxt integration for the SCAYLE Commerce Engine and Storefront API
606 lines (595 loc) • 20.9 kB
JavaScript
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 };