@salesforce/storefront-next-dev 0.4.1 → 1.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,40 +1,19 @@
1
- import path, { basename, extname, join, resolve } from "node:path";
1
+ import path from "node:path";
2
2
  import fs from "fs-extra";
3
3
  import chalk from "chalk";
4
- import path$1, { dirname, join as join$1, relative, resolve as resolve$1 } from "path";
4
+ import path$1, { dirname, join, relative, resolve } from "path";
5
5
  import { fileURLToPath, pathToFileURL } from "url";
6
6
  import { parse } from "@babel/parser";
7
7
  import { booleanLiteral, identifier, importDeclaration, importSpecifier, isArrayPattern, isClassDeclaration, isExportSpecifier, isFunctionDeclaration, isIdentifier, isJSXAttribute, isJSXElement, isJSXFragment, isJSXIdentifier, isMemberExpression, isObjectPattern, isObjectProperty, isRestElement, isStringLiteral, isVariableDeclaration, jsxAttribute, jsxClosingElement, jsxClosingFragment, jsxElement, jsxExpressionContainer, jsxFragment, jsxIdentifier, jsxOpeningElement, jsxOpeningFragment, jsxText, stringLiteral } from "@babel/types";
8
8
  import { generate } from "@babel/generator";
9
9
  import traverseModule from "@babel/traverse";
10
- import fs$1, { existsSync, readFileSync, writeFileSync } from "fs";
10
+ import { existsSync, readFileSync, writeFileSync } from "fs";
11
11
  import { glob } from "glob";
12
12
  import { Node, Project, ts } from "ts-morph";
13
- import fs$2, { existsSync as existsSync$1, readFileSync as readFileSync$1, unlinkSync } from "node:fs";
13
+ import fs$1 from "node:fs";
14
14
  import { deadCodeElimination, findReferencedIdentifiers } from "babel-dead-code-elimination";
15
15
  import httpProxy from "http-proxy";
16
16
  import { brotliDecompressSync, gunzipSync, inflateSync } from "zlib";
17
- import express from "express";
18
- import { createRequestHandler } from "@react-router/express";
19
- import { pathToFileURL as pathToFileURL$1 } from "node:url";
20
- import { createProxyMiddleware } from "http-proxy-middleware";
21
- import compression from "compression";
22
- import zlib from "node:zlib";
23
- import morgan from "morgan";
24
- import { minimatch } from "minimatch";
25
- import { SpanStatusCode, context, trace } from "@opentelemetry/api";
26
- import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
27
- import { ConsoleSpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
28
- import { ExportResultCode, hrTimeToTimeStamp } from "@opentelemetry/core";
29
- import { Resource } from "@opentelemetry/resources";
30
- import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
31
- import { registerInstrumentations } from "@opentelemetry/instrumentation";
32
- import { UndiciInstrumentation } from "@opentelemetry/instrumentation-undici";
33
- import { access, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
34
- import { execSync } from "node:child_process";
35
- import { tmpdir } from "node:os";
36
- import { randomUUID } from "node:crypto";
37
- import { npmRunPathEnv } from "npm-run-path";
38
17
 
39
18
  //#region src/utils/logger.ts
40
19
  const LEVEL_PRIORITY = {
@@ -159,49 +138,6 @@ function fixReactRouterManifestUrlsPlugin() {
159
138
  function toPosixPath(filePath) {
160
139
  return filePath.replace(/\\/g, "/");
161
140
  }
162
- /**
163
- * Get the Commerce Cloud API URL from a short code
164
- */
165
- function getCommerceCloudApiUrl(shortCode, proxyHost) {
166
- return proxyHost || `https://${shortCode}.api.commercecloud.salesforce.com`;
167
- }
168
- /**
169
- * Get the configurable base path for the application.
170
- * Reads from MRT_ENV_BASE_PATH environment variable.
171
- *
172
- * The base path is used for CDN routing to the correct MRT environment.
173
- * It is prepended to all URLs: page routes, /mobify/bundle/ assets, and /mobify/proxy/api.
174
- *
175
- * Validation rules:
176
- * - Must be a single path segment starting with '/'
177
- * - Max 63 characters after the leading slash
178
- * - Only URL-safe characters allowed
179
- * - Returns empty string if not set
180
- *
181
- * @returns The sanitized base path (e.g., '/site-a' or '')
182
- *
183
- * @example
184
- * // No base path configured
185
- * getBasePath() // → ''
186
- *
187
- * // With base path '/storefront'
188
- * getBasePath() // → '/storefront'
189
- *
190
- * // Automatically sanitizes
191
- * // MRT_ENV_BASE_PATH='storefront/' → '/storefront'
192
- */
193
- function getBasePath() {
194
- const basePath = process.env.MRT_ENV_BASE_PATH?.trim();
195
- if (!basePath) return "";
196
- if (!/^\/[a-zA-Z0-9_.+$~"'@:-]{1,63}$/.test(basePath)) throw new Error(`Invalid base path: "${basePath}". Base path must be a single segment starting with '/' (e.g., '/site-a'), contain only URL-safe characters, and be at most 63 characters after the leading slash.`);
197
- return basePath;
198
- }
199
- /**
200
- * Get the bundle path for static assets
201
- */
202
- function getBundlePath(bundleId) {
203
- return `${getBasePath()}/mobify/bundle/${bundleId}/client/`;
204
- }
205
141
 
206
142
  //#endregion
207
143
  //#region src/plugins/readableChunkFileNames.ts
@@ -364,8 +300,11 @@ const managedRuntimeBundlePlugin = () => {
364
300
  defaultHandler(level, log);
365
301
  } } },
366
302
  environments: { ssr: { resolve: { noExternal: true } } },
367
- experimental: { renderBuiltUrl(filename, { type }) {
368
- if (mode !== "preview" && (type === "asset" || type === "public")) return { runtime: `(typeof window !== 'undefined' ? window._BUNDLE_PATH : ((process.env.MRT_ENV_BASE_PATH??'')+'/mobify/bundle/'+(process.env.BUNDLE_ID??'local')+'/client/')) + ${JSON.stringify(filename)}` };
303
+ experimental: { renderBuiltUrl(filename, { type, hostType }) {
304
+ if (mode !== "preview" && (type === "asset" || type === "public")) {
305
+ if (hostType === "css") return { relative: true };
306
+ return { runtime: `(typeof window !== 'undefined' ? window._BUNDLE_PATH : ((process.env.MRT_ENV_BASE_PATH??'')+'/mobify/bundle/'+(process.env.BUNDLE_ID??'local')+'/client/')) + ${JSON.stringify(filename)}` };
307
+ }
369
308
  } }
370
309
  };
371
310
  },
@@ -580,13 +519,14 @@ function transformTargets(code, targetRegistry, contextProviders) {
580
519
  function buildTargetRegistry(rootDir, options = {}) {
581
520
  const componentRegistry = {};
582
521
  const contextProviders = [];
522
+ const actionHookRegistry = {};
583
523
  const extensionDirPath = path$1.join(rootDir, "extensions");
584
524
  const extensionDirs = fs.readdirSync(extensionDirPath, { withFileTypes: true });
585
525
  const getNamespaceAndComponentName = (dir, filePath) => {
586
526
  const namespace = dir.name.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join("");
587
527
  return {
588
528
  namespace,
589
- componentName: `${namespace}_${(filePath.split("/").pop()?.replace(".tsx", ""))?.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join("")}`
529
+ componentName: `${namespace}_${(filePath.split("/").pop()?.replace(/\.(tsx|ts|jsx|js)$/, ""))?.split(/[-.]/).map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join("")}`
590
530
  };
591
531
  };
592
532
  const TARGET_CONFIG_FILENAME = "target-config.json";
@@ -596,6 +536,7 @@ function buildTargetRegistry(rootDir, options = {}) {
596
536
  const extensionConfig = fs.readJsonSync(configPath);
597
537
  if (options.isProduction && extensionConfig.devOnly === true) continue;
598
538
  if (extensionConfig && extensionConfig.components) for (const component of extensionConfig.components) {
539
+ if (component.enabled === false) continue;
599
540
  const { targetId, path: componentPath, order = 0 } = component;
600
541
  if (targetId && componentPath) {
601
542
  if (!componentRegistry[targetId]) componentRegistry[targetId] = [];
@@ -621,13 +562,47 @@ function buildTargetRegistry(rootDir, options = {}) {
621
562
  });
622
563
  }
623
564
  }
565
+ if (extensionConfig && extensionConfig.actionHooks) for (const hook of extensionConfig.actionHooks) {
566
+ const { hookId, handler, order = 0 } = hook;
567
+ if (hookId && handler) {
568
+ if (!actionHookRegistry[hookId]) actionHookRegistry[hookId] = [];
569
+ const { namespace, componentName: handlerName } = getNamespaceAndComponentName(dir, handler);
570
+ actionHookRegistry[hookId].push({
571
+ hookId,
572
+ path: handler,
573
+ order,
574
+ namespace,
575
+ handlerName
576
+ });
577
+ }
578
+ }
624
579
  }
625
580
  }
626
581
  for (const targetId in componentRegistry) componentRegistry[targetId].sort((a, b) => a.order - b.order);
627
582
  contextProviders.sort((a, b) => a.order - b.order);
583
+ for (const hookId in actionHookRegistry) actionHookRegistry[hookId].sort((a, b) => a.order - b.order);
584
+ for (const targetId in componentRegistry) {
585
+ const entries = componentRegistry[targetId];
586
+ const seen = /* @__PURE__ */ new Map();
587
+ for (const entry of entries) {
588
+ const existing = seen.get(entry.order);
589
+ if (existing) logger.warn(`[storefront-next] UITarget "${targetId}": components "${existing}" and "${entry.componentName}" have the same order (${entry.order}). Execution order between them is non-deterministic. Assign distinct order values.`);
590
+ seen.set(entry.order, entry.componentName);
591
+ }
592
+ }
593
+ for (const hookId in actionHookRegistry) {
594
+ const entries = actionHookRegistry[hookId];
595
+ const seen = /* @__PURE__ */ new Map();
596
+ for (const entry of entries) {
597
+ const existing = seen.get(entry.order);
598
+ if (existing) logger.warn(`[storefront-next] Action hook "${hookId}": handlers "${existing}" and "${entry.handlerName}" have the same order (${entry.order}). Execution order between them is non-deterministic. Assign distinct order values.`);
599
+ seen.set(entry.order, entry.handlerName);
600
+ }
601
+ }
628
602
  return {
629
603
  componentRegistry,
630
- contextProviders
604
+ contextProviders,
605
+ actionHookRegistry
631
606
  };
632
607
  }
633
608
 
@@ -665,6 +640,96 @@ function transformTargetPlaceholderPlugin() {
665
640
  };
666
641
  }
667
642
 
643
+ //#endregion
644
+ //#region src/plugins/actionHooks.ts
645
+ const ACTION_HOOKS_VIRTUAL_ID = "virtual:action-hooks";
646
+ const ACTION_HOOKS_RESOLVED_ID = `\0${ACTION_HOOKS_VIRTUAL_ID}`;
647
+ /**
648
+ * Generate the virtual module code for the action hook registry.
649
+ *
650
+ * The generated module exports a `hookRegistry` map of hookId → handler[],
651
+ * and a `runHook` function that executes registered handlers in order.
652
+ */
653
+ function generateActionHooksModule(actionHookRegistry) {
654
+ const imports = [];
655
+ const registryEntries = [];
656
+ for (const [hookId, handlers] of Object.entries(actionHookRegistry)) {
657
+ const handlerNames = [];
658
+ for (const handler of handlers) {
659
+ const importPath = `@/${handler.path.replace(/\.(ts|tsx|js|jsx)$/, "")}`;
660
+ imports.push(`import ${handler.handlerName} from '${importPath}';`);
661
+ handlerNames.push(handler.handlerName);
662
+ }
663
+ registryEntries.push(` '${hookId}': [${handlerNames.join(", ")}]`);
664
+ }
665
+ return `${imports.join("\n")}
666
+
667
+ const HANDLER_TIMEOUT_MS = 5000;
668
+
669
+ const hookRegistry = {
670
+ ${registryEntries.join(",\n")}
671
+ };
672
+
673
+ function withTimeout(promise, ms, label) {
674
+ return Promise.race([
675
+ promise,
676
+ new Promise((_, reject) =>
677
+ setTimeout(() => reject(new Error(\`Action hook handler timed out after \${ms}ms: \${label}\`)), ms)
678
+ ),
679
+ ]);
680
+ }
681
+
682
+ export async function runHook(hookId, context, options = {}) {
683
+ const handlers = hookRegistry[hookId];
684
+ if (!handlers || handlers.length === 0) {
685
+ return context;
686
+ }
687
+ let currentContext = context;
688
+ for (const handler of handlers) {
689
+ try {
690
+ const result = await withTimeout(handler(currentContext), HANDLER_TIMEOUT_MS, hookId);
691
+ currentContext = result ?? currentContext;
692
+ } catch (error) {
693
+ if (error && error.name === 'ActionHookError') {
694
+ throw error;
695
+ }
696
+ if (options.blocking) {
697
+ throw error;
698
+ }
699
+ console.error(\`[action-hooks] handler for "\${hookId}" failed, skipping to next handler\`, error);
700
+ }
701
+ }
702
+ return currentContext;
703
+ }
704
+ `;
705
+ }
706
+ /**
707
+ * Vite plugin that resolves `virtual:action-hooks` to a generated module
708
+ * mapping hookIds to their registered handlers.
709
+ */
710
+ function actionHooksPlugin() {
711
+ let actionHookRegistry;
712
+ let sourceDir;
713
+ let isProduction = false;
714
+ return {
715
+ name: "storefront-next:action-hooks",
716
+ enforce: "pre",
717
+ configResolved(config) {
718
+ sourceDir = config.resolve.alias.find((alias) => alias.find === "@")?.replacement || path$1.resolve(__dirname, "./src");
719
+ isProduction = config.mode === "production";
720
+ },
721
+ buildStart() {
722
+ actionHookRegistry = buildTargetRegistry(sourceDir, { isProduction }).actionHookRegistry;
723
+ },
724
+ resolveId(id) {
725
+ if (id === ACTION_HOOKS_VIRTUAL_ID) return ACTION_HOOKS_RESOLVED_ID;
726
+ },
727
+ load(id) {
728
+ if (id === ACTION_HOOKS_RESOLVED_ID) return generateActionHooksModule(actionHookRegistry);
729
+ }
730
+ };
731
+ }
732
+
668
733
  //#endregion
669
734
  //#region src/plugins/watchConfigFiles.ts
670
735
  const watchConfigFilesPlugin = () => {
@@ -694,7 +759,7 @@ const watchConfigFilesPlugin = () => {
694
759
 
695
760
  //#endregion
696
761
  //#region src/plugins/staticRegistry.ts
697
- const DEFAULT_COMPONENT_GROUP$1 = "storefrontnext_base";
762
+ const DEFAULT_COMPONENT_GROUP = "storefrontnext_base";
698
763
  /**
699
764
  * Extracts component ID and group from @Component decorator using ts-morph AST parsing
700
765
  */
@@ -709,7 +774,7 @@ function extractComponentInfo(decorator) {
709
774
  else if (Node.isNoSubstitutionTemplateLiteral(firstArg)) baseComponentId = firstArg.getText().slice(1, -1);
710
775
  else if (Node.isTemplateExpression(firstArg)) throw new Error(`@Component id must be a simple string literal or backtick string without interpolation. Found: ${firstArg.getText()}`);
711
776
  else return null;
712
- let group = DEFAULT_COMPONENT_GROUP$1;
777
+ let group = DEFAULT_COMPONENT_GROUP;
713
778
  if (args.length > 1) {
714
779
  const secondArg = args[1];
715
780
  if (Node.isObjectLiteralExpression(secondArg)) {
@@ -768,7 +833,7 @@ async function scanComponents(project, projectRoot, componentPath, registryPath)
768
833
  });
769
834
  logger.debug(`🔍 Scanning ${componentFiles.length} files in ${componentPath}...`);
770
835
  const components = [];
771
- const registryDir = dirname(resolve$1(projectRoot, registryPath));
836
+ const registryDir = dirname(resolve(projectRoot, registryPath));
772
837
  for (const filePath of componentFiles) try {
773
838
  const content = readFileSync(filePath, "utf-8");
774
839
  const sourceFile = project.createSourceFile(filePath, content, { overwrite: true });
@@ -925,7 +990,7 @@ const staticRegistryPlugin = (config = {}) => {
925
990
  } }), projectRoot, componentPath, registryPath);
926
991
  logger.debug(`📦 Found ${components.length} components with @Component decorators`);
927
992
  const generatedCode = generateRegistryCode(components, registryIdentifier);
928
- const registryFilePath = resolve$1(projectRoot, registryPath);
993
+ const registryFilePath = resolve(projectRoot, registryPath);
929
994
  const changed = updateRegistryFile(registryFilePath, generatedCode);
930
995
  logger.debug("✅ Static registry generation complete!");
931
996
  return {
@@ -974,7 +1039,7 @@ const staticRegistryPlugin = (config = {}) => {
974
1039
  * Load the engagement config from config.server.ts
975
1040
  */
976
1041
  async function loadEngagementConfig(projectRoot, configPath) {
977
- const absoluteConfigPath = resolve$1(projectRoot, configPath);
1042
+ const absoluteConfigPath = resolve(projectRoot, configPath);
978
1043
  try {
979
1044
  const config = (await import(pathToFileURL(absoluteConfigPath).href)).default;
980
1045
  logger.debug(`📄 Loaded config from ${configPath}`);
@@ -1001,7 +1066,7 @@ async function scanForInstrumentedEvents(projectRoot, scanPaths) {
1001
1066
  const sendViewPagePattern = /sendViewPageEvent\s*\(/g;
1002
1067
  const createEventPattern = /createEvent\s*\(\s*['"]([^'"]+)['"]/g;
1003
1068
  for (const scanPath of scanPaths) {
1004
- const files = await glob(join$1(resolve$1(projectRoot, scanPath), "**/*.{ts,tsx}"), { ignore: [
1069
+ const files = await glob(join(resolve(projectRoot, scanPath), "**/*.{ts,tsx}"), { ignore: [
1005
1070
  "**/*.test.ts",
1006
1071
  "**/*.test.tsx",
1007
1072
  "**/*.spec.ts",
@@ -1153,17 +1218,17 @@ const buildMiddlewareRegistryPlugin = () => {
1153
1218
  configResolved(config) {
1154
1219
  resolvedConfig = config;
1155
1220
  const rr = config.__reactRouterPluginContext?.reactRouterConfig ?? {};
1156
- buildDirectory = rr.buildDirectory ?? resolve$1(config.root, "build");
1221
+ buildDirectory = rr.buildDirectory ?? resolve(config.root, "build");
1157
1222
  appDirectory = rr.appDirectory ?? "src";
1158
1223
  },
1159
1224
  buildApp: {
1160
1225
  order: "post",
1161
1226
  handler: async () => {
1162
1227
  const projectRoot = resolvedConfig.root;
1163
- const middlewareRegistryPath = resolve$1(projectRoot, appDirectory, SERVER_OUT_SUBDIR, MIDDLEWARE_REGISTRY_SOURCE_FILE);
1228
+ const middlewareRegistryPath = resolve(projectRoot, appDirectory, SERVER_OUT_SUBDIR, MIDDLEWARE_REGISTRY_SOURCE_FILE);
1164
1229
  if (!existsSync(middlewareRegistryPath)) return;
1165
1230
  const { build } = await import("tsdown");
1166
- const serverOutDir = resolve$1(projectRoot, buildDirectory, SERVER_OUT_SUBDIR);
1231
+ const serverOutDir = resolve(projectRoot, buildDirectory, SERVER_OUT_SUBDIR);
1167
1232
  await build({
1168
1233
  cwd: projectRoot,
1169
1234
  entry: { [MIDDLEWARE_REGISTRY_SOURCE_FILE.replace(/\.ts$/, "")]: middlewareRegistryPath },
@@ -1213,10 +1278,10 @@ const PASSTHROUGH_QUERY = "?platform-passthrough";
1213
1278
  * Finds a user-ejected entry file in the app directory.
1214
1279
  * Returns the absolute path if found, undefined otherwise.
1215
1280
  */
1216
- function findUserEntry(appDirectory, basename$1) {
1281
+ function findUserEntry(appDirectory, basename) {
1217
1282
  for (const ext of ENTRY_EXTENSIONS) {
1218
- const filePath = path.resolve(appDirectory, basename$1 + ext);
1219
- if (fs$2.existsSync(filePath)) return filePath;
1283
+ const filePath = path.resolve(appDirectory, basename + ext);
1284
+ if (fs$1.existsSync(filePath)) return filePath;
1220
1285
  }
1221
1286
  }
1222
1287
  /**
@@ -1316,8 +1381,8 @@ function platformEntryPlugin() {
1316
1381
  const watcher = server.watcher;
1317
1382
  const checkEntryChange = (filePath) => {
1318
1383
  const relative$1 = path.relative(appDir, filePath);
1319
- const basename$1 = path.basename(relative$1, path.extname(relative$1));
1320
- if (path.dirname(relative$1) !== "." || basename$1 !== "entry.server" && basename$1 !== "entry.client") return;
1384
+ const basename = path.basename(relative$1, path.extname(relative$1));
1385
+ if (path.dirname(relative$1) !== "." || basename !== "entry.server" && basename !== "entry.client") return;
1321
1386
  const ext = path.extname(relative$1);
1322
1387
  if (!ENTRY_EXTENSIONS.includes(ext)) return;
1323
1388
  const nowHasServer = findUserEntry(appDir, "entry.server") !== void 0;
@@ -1715,6 +1780,56 @@ function i18nPlugin(config) {
1715
1780
  };
1716
1781
  }
1717
1782
 
1783
+ //#endregion
1784
+ //#region src/plugins/baseConfig.ts
1785
+ /**
1786
+ * Vite plugin contributing the baseline Vite config required by the
1787
+ * Storefront Next framework. These settings are uniform across every
1788
+ * customer project and are not intended to be customized.
1789
+ *
1790
+ * Additional framework-level Vite defaults should be added here rather
1791
+ * than in the template's vite.config.ts or in a new single-purpose plugin.
1792
+ *
1793
+ * Current defaults:
1794
+ * - `resolve.dedupe`: prevents duplicate React / React Router copies on the
1795
+ * client. Duplicate React instances cause hooks to throw "Invalid hook call".
1796
+ * - `optimizeDeps.include`: forces Vite's dep optimizer to pre-bundle
1797
+ * `react-router` and its `/internal/react-server-client` entry so the
1798
+ * React Router dev plugin resolves a single shared instance on the client.
1799
+ * - `ssr.noExternal`: forces the SDK through Vite's SSR transform pipeline
1800
+ * in dev. The SDK exports module-level singletons (router contexts, etc.)
1801
+ * whose object identity is load-bearing — they're used as `Map` keys, so
1802
+ * reads and writes must reference the same object. In dev SSR, Vite can
1803
+ * externalize a package for some import sites (loaded by Node's native
1804
+ * ESM resolver) while transforming it for others (loaded by Vite's SSR
1805
+ * transform pipeline), producing two distinct module records for the
1806
+ * same file on disk. When that happens, the singletons are constructed
1807
+ * twice and the keys no longer match — context lookups silently return
1808
+ * the default value. `noExternal` collapses both paths into Vite's
1809
+ * transform cache so there is exactly one module record. Production
1810
+ * builds inline the SDK into the SSR bundle and are unaffected. We
1811
+ * don't blanket-`noExternal` every dependency because most third-party
1812
+ * packages are identity-agnostic (two copies work fine) and externalizing
1813
+ * keeps dev startup fast — only packages exporting identity-sensitive
1814
+ * singletons need this treatment.
1815
+ *
1816
+ * @returns {Plugin} A Vite plugin contributing the framework's base config.
1817
+ */
1818
+ const baseConfigPlugin = () => ({
1819
+ name: "storefront-next:base-config",
1820
+ config() {
1821
+ return {
1822
+ resolve: { dedupe: [
1823
+ "react",
1824
+ "react-dom",
1825
+ "react-router"
1826
+ ] },
1827
+ optimizeDeps: { include: ["react-router", "react-router/internal/react-server-client"] },
1828
+ ssr: { noExternal: ["@salesforce/storefront-next-runtime"] }
1829
+ };
1830
+ }
1831
+ });
1832
+
1718
1833
  //#endregion
1719
1834
  //#region src/storefront-next-targets.ts
1720
1835
  /**
@@ -1746,6 +1861,7 @@ function storefrontNextTargets(config = {}) {
1746
1861
  failOnMissing: false
1747
1862
  } } = config;
1748
1863
  const plugins = [
1864
+ baseConfigPlugin(),
1749
1865
  ...process.env.SCAPI_PROXY_HOST ? [workspacePlugin()] : [],
1750
1866
  i18nPlugin(),
1751
1867
  managedRuntimeBundlePlugin(),
@@ -1753,6 +1869,7 @@ function storefrontNextTargets(config = {}) {
1753
1869
  patchReactRouterPlugin(),
1754
1870
  platformEntryPlugin(),
1755
1871
  transformTargetPlaceholderPlugin(),
1872
+ actionHooksPlugin(),
1756
1873
  watchConfigFilesPlugin(),
1757
1874
  buildMiddlewareRegistryPlugin(),
1758
1875
  ssrSourcemapFixPlugin()
@@ -1959,6 +2076,55 @@ function escapeRegExp(str) {
1959
2076
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1960
2077
  }
1961
2078
  /**
2079
+ * Rewrite an SFCC Location header so the user stays on the proxy origin AND so
2080
+ * any query params from the original request survive an SFCC redirect.
2081
+ *
2082
+ * SFCC frequently redirects bare paths like `/cart` to a canonical SFRA URL
2083
+ * (`/s/{siteId}/{locale}/Cart-Show`) without echoing the user's query string in
2084
+ * the Location header. Without this merge step, params like `?foo=bar` set by
2085
+ * the storefront — including ones the destination page expects — get dropped on
2086
+ * the cross-app hop.
2087
+ *
2088
+ * Resolution rules:
2089
+ * - Off-origin Location → `{ kind: 'off-origin' }` so the caller leaves the
2090
+ * header unchanged and the browser navigates as SFCC intended.
2091
+ * - Same-origin Location → rewrite the origin to the proxy host, then merge the
2092
+ * original request's query params into the redirect target's URL. Multi-value
2093
+ * keys (e.g. SFRA's `pmid=...&pmid=...`) are preserved on both sides. The
2094
+ * redirect target wins on collision so SFCC can intentionally override a key.
2095
+ * - Malformed Location → `{ kind: 'malformed' }` so the caller can warn.
2096
+ *
2097
+ * @param locationHeader - Raw Location header value from the SFCC response.
2098
+ * @param requestUrl - Original request URL on the proxy (e.g. `/cart?foo=bar`).
2099
+ * @param targetOrigin - SFCC origin used as the base for relative Location values.
2100
+ * @param proxyOrigin - Proxy origin (e.g. `http://localhost:5173`) the caller wants the user to stay on.
2101
+ */
2102
+ function rewriteLocationForProxy({ locationHeader, requestUrl, targetOrigin, proxyOrigin }) {
2103
+ let locationUrl;
2104
+ try {
2105
+ locationUrl = new URL(locationHeader, targetOrigin);
2106
+ } catch {
2107
+ return { kind: "malformed" };
2108
+ }
2109
+ if (locationUrl.origin !== targetOrigin) return { kind: "off-origin" };
2110
+ let requestQuery;
2111
+ try {
2112
+ requestQuery = new URL(requestUrl, proxyOrigin).searchParams;
2113
+ } catch {
2114
+ requestQuery = new URLSearchParams();
2115
+ }
2116
+ const targetKeys = new Set(locationUrl.searchParams.keys());
2117
+ for (const [key, value] of requestQuery) if (!targetKeys.has(key)) locationUrl.searchParams.append(key, value);
2118
+ return {
2119
+ kind: "rewritten",
2120
+ url: `${proxyOrigin}${locationUrl.pathname}${locationUrl.search}${locationUrl.hash}`
2121
+ };
2122
+ }
2123
+ /** Cap loud values in debug logs so a long URL doesn't drown the dev console. */
2124
+ function truncateForLog(value, max = 120) {
2125
+ return value.length > max ? `${value.slice(0, max)}…` : value;
2126
+ }
2127
+ /**
1962
2128
  * Vite plugin for hybrid proxying between Storefront Next and legacy SFRA.
1963
2129
  *
1964
2130
  * Uses http-proxy to silently forward non-matching requests to SFCC without visible
@@ -1980,10 +2146,12 @@ function hybridProxyPlugin(options) {
1980
2146
  logger.warn("Hybrid proxy: no target origin configured (SFCC_ORIGIN required)");
1981
2147
  return { name: "hybrid-proxy" };
1982
2148
  }
2149
+ if (!options.defaultSiteId) throw new Error("Hybrid proxy is enabled but no default site ID was provided.\n\nSet PUBLIC__app__defaultSiteId in your .env file:\n PUBLIC__app__defaultSiteId=RefArchGlobal\n\nSee docs/README-HYBRID-PROXY.md for the full reference.");
1983
2150
  logger.info(`Hybrid proxy enabled → ${options.targetOrigin}`);
1984
2151
  logger.debug(`Hybrid proxy routing rules: ${options.routingRules.slice(0, 100)}...`);
1985
2152
  const locale = options.locale || "default";
1986
- logger.debug(`Hybrid proxy path transformation: / → /s/${options.siteId}, /path → /s/${options.siteId}/${locale}/path`);
2153
+ const defaultSiteId = options.defaultSiteId;
2154
+ logger.debug(`Hybrid proxy path transformation: / → /s/${defaultSiteId}, /path → /s/${defaultSiteId}/${locale}/path`);
1987
2155
  const targetOriginPattern = new RegExp(escapeRegExp(options.targetOrigin), "g");
1988
2156
  const proxy = httpProxy.createProxyServer({
1989
2157
  changeOrigin: true,
@@ -2000,8 +2168,8 @@ function hybridProxyPlugin(options) {
2000
2168
  * This would simply proxy to SFCC hostname (eg.: https://zzrf-001.dx.commercecloud.salesforce.com/s/{siteId}/{locale}/) which is not a valid storefront URL.
2001
2169
  * We need to rewrite the path to /s/{siteId} so that it can be proxied to the correct SFCC URL.
2002
2170
  */
2003
- if (pathname === "/") proxyReq.path = `/s/${options.siteId}${url.search}`;
2004
- else proxyReq.path = `/s/${options.siteId}/${locale}${pathname}${url.search}`;
2171
+ if (pathname === "/") proxyReq.path = `/s/${defaultSiteId}${url.search}`;
2172
+ else proxyReq.path = `/s/${defaultSiteId}/${locale}${pathname}${url.search}`;
2005
2173
  logger.debug(`Hybrid proxy path rewrite: ${originalPath} → ${proxyReq.path}`);
2006
2174
  }
2007
2175
  });
@@ -2009,7 +2177,7 @@ function hybridProxyPlugin(options) {
2009
2177
  const clientRes = res;
2010
2178
  const locationHeader = proxyRes.headers.location;
2011
2179
  const statusCode = proxyRes.statusCode || 200;
2012
- if (statusCode >= 300 && statusCode < 400 && typeof locationHeader === "string" && /\/404\b/.test(locationHeader)) {
2180
+ if (typeof locationHeader === "string" && /\/404\b/.test(locationHeader) && (statusCode >= 300 && statusCode < 400 || statusCode === 200)) {
2013
2181
  logger.warn(`⚠️ SFCC returned a redirect to 404 for ${req.url}. This usually means your HYBRID_ROUTING_RULES are missing a pattern for this path. Stripping Set-Cookie headers to prevent session cookie corruption. Fix: add a matching pattern to HYBRID_ROUTING_RULES (e.g., "^${req.url?.split("?")[0]}.*")`);
2014
2182
  delete proxyRes.headers["set-cookie"];
2015
2183
  }
@@ -2019,15 +2187,28 @@ function hybridProxyPlugin(options) {
2019
2187
  logger.debug(`Hybrid proxy cookie rewrite: ${cookie.slice(0, 50)}... → ${rewritten.slice(0, 50)}...`);
2020
2188
  return rewritten;
2021
2189
  });
2022
- if (locationHeader && typeof locationHeader === "string") try {
2023
- const locationUrl = new URL(locationHeader, options.targetOrigin);
2024
- if (locationUrl.origin === options.targetOrigin) {
2025
- const localUrl = `http://${req.headers.host}${locationUrl.pathname}${locationUrl.search}${locationUrl.hash}`;
2026
- proxyRes.headers.location = localUrl;
2027
- logger.debug(`Hybrid proxy location rewrite: ${locationHeader} → ${localUrl}`);
2028
- }
2029
- } catch {
2030
- logger.warn(`Hybrid proxy: invalid Location header: ${locationHeader}`);
2190
+ if (locationHeader && typeof locationHeader === "string") {
2191
+ const proxyOrigin = `http://${req.headers.host}`;
2192
+ const result = rewriteLocationForProxy({
2193
+ locationHeader,
2194
+ requestUrl: req.url ?? "",
2195
+ targetOrigin: options.targetOrigin,
2196
+ proxyOrigin
2197
+ });
2198
+ if (result.kind === "rewritten") {
2199
+ proxyRes.headers.location = result.url;
2200
+ logger.debug(`Hybrid proxy location rewrite: ${truncateForLog(locationHeader)} → ${truncateForLog(result.url)}`);
2201
+ if (statusCode === 200) {
2202
+ proxyRes.resume();
2203
+ const redirectHeaders = { location: result.url };
2204
+ const setCookie = proxyRes.headers["set-cookie"];
2205
+ if (setCookie) redirectHeaders["set-cookie"] = setCookie;
2206
+ clientRes.writeHead(302, redirectHeaders);
2207
+ clientRes.end();
2208
+ logger.debug(`Hybrid proxy normalized 200+Location → 302 for ${req.url} (Location: ${truncateForLog(result.url)})`);
2209
+ return;
2210
+ }
2211
+ } else if (result.kind === "malformed") logger.warn(`Hybrid proxy: invalid Location header: ${truncateForLog(locationHeader)}`);
2031
2212
  }
2032
2213
  const contentType = (proxyRes.headers["content-type"] || "").split(";")[0].trim();
2033
2214
  if (!(contentType === "text/html" || contentType === "application/json")) {
@@ -2212,1481 +2393,7 @@ function shouldRouteToNext(pathname, routingRules) {
2212
2393
  }
2213
2394
  return testPatterns(pathname, patterns);
2214
2395
  }
2215
- /**
2216
- * Clears the regex cache. Useful for testing or when routing rules change.
2217
- *
2218
- * @example
2219
- * ```typescript
2220
- * clearCache();
2221
- * // All cached regex patterns are removed
2222
- * ```
2223
- */
2224
- function clearCache() {
2225
- regexCache.clear();
2226
- }
2227
-
2228
- //#endregion
2229
- //#region src/server/ts-import.ts
2230
- /**
2231
- * Parse TypeScript paths from tsconfig.json and convert to jiti alias format.
2232
- *
2233
- * @param tsconfigPath - Path to tsconfig.json
2234
- * @param projectDirectory - Project root directory for resolving relative paths
2235
- * @returns Record of alias mappings for jiti
2236
- *
2237
- * @example
2238
- * // tsconfig.json: { "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }
2239
- * // Returns: { "@/": "/absolute/path/to/src/" }
2240
- */
2241
- function parseTsconfigPaths(tsconfigPath, projectDirectory) {
2242
- const alias = {};
2243
- if (!existsSync$1(tsconfigPath)) return alias;
2244
- try {
2245
- const tsconfigContent = readFileSync$1(tsconfigPath, "utf-8");
2246
- const tsconfig = JSON.parse(tsconfigContent);
2247
- const paths = tsconfig.compilerOptions?.paths;
2248
- const baseUrl = tsconfig.compilerOptions?.baseUrl || ".";
2249
- if (paths) {
2250
- for (const [key, values] of Object.entries(paths)) if (values && values.length > 0) {
2251
- const aliasKey = key.replace(/\/\*$/, "/");
2252
- alias[aliasKey] = resolve(projectDirectory, baseUrl, values[0].replace(/\/\*$/, "/").replace(/^\.\//, ""));
2253
- }
2254
- }
2255
- } catch {}
2256
- const sortedAlias = {};
2257
- Object.keys(alias).sort((a, b) => b.length - a.length).forEach((key) => {
2258
- sortedAlias[key] = alias[key];
2259
- });
2260
- return sortedAlias;
2261
- }
2262
- /**
2263
- * Import a TypeScript file using jiti with proper path alias resolution.
2264
- * This is a cross-platform alternative to tsx that works on Windows.
2265
- *
2266
- * @param filePath - Absolute path to the TypeScript file to import
2267
- * @param options - Import options including project directory
2268
- * @returns The imported module
2269
- */
2270
- async function importTypescript(filePath, options) {
2271
- const { projectDirectory, tsconfigPath = resolve(projectDirectory, "tsconfig.json") } = options;
2272
- const { createJiti } = await import("jiti");
2273
- const alias = parseTsconfigPaths(tsconfigPath, projectDirectory);
2274
- return createJiti(import.meta.url, {
2275
- fsCache: false,
2276
- interopDefault: true,
2277
- alias
2278
- }).import(filePath);
2279
- }
2280
-
2281
- //#endregion
2282
- //#region src/server/config.ts
2283
- /**
2284
- * This is a temporary function before we move the config implementation from
2285
- * template-retail-rsc-app to the SDK.
2286
- *
2287
- * @ TODO: Remove this function after we move the config implementation from
2288
- * template-retail-rsc-app to the SDK.
2289
- *
2290
- */
2291
- function loadConfigFromEnv() {
2292
- const shortCode = process.env.PUBLIC__app__commerce__api__shortCode;
2293
- const organizationId = process.env.PUBLIC__app__commerce__api__organizationId;
2294
- const clientId = process.env.PUBLIC__app__commerce__api__clientId;
2295
- const proxy = process.env.PUBLIC__app__commerce__api__proxy || "/mobify/proxy/api";
2296
- const proxyHost = process.env.SCAPI_PROXY_HOST;
2297
- if (!shortCode && !proxyHost) throw new Error("Missing PUBLIC__app__commerce__api__shortCode environment variable.\nPlease set it in your .env file or environment.");
2298
- if (!organizationId) throw new Error("Missing PUBLIC__app__commerce__api__organizationId environment variable.\nPlease set it in your .env file or environment.");
2299
- if (!clientId) throw new Error("Missing PUBLIC__app__commerce__api__clientId environment variable.\nPlease set it in your .env file or environment.");
2300
- return { commerce: { api: {
2301
- shortCode: shortCode || "",
2302
- organizationId,
2303
- clientId,
2304
- proxy,
2305
- proxyHost
2306
- } } };
2307
- }
2308
- /**
2309
- * Load storefront-next project configuration from config.server.ts.
2310
- * Requires projectDirectory to be provided.
2311
- *
2312
- * @param projectDirectory - Project directory to load config.server.ts from
2313
- * @throws Error if config.server.ts is not found or invalid
2314
- */
2315
- async function loadProjectConfig(projectDirectory) {
2316
- const configPath = resolve(projectDirectory, "config.server.ts");
2317
- const tsconfigPath = resolve(projectDirectory, "tsconfig.json");
2318
- if (!existsSync$1(configPath)) throw new Error(`config.server.ts not found at ${configPath}.\nPlease ensure config.server.ts exists in your project root.`);
2319
- const config = (await importTypescript(configPath, {
2320
- projectDirectory,
2321
- tsconfigPath
2322
- })).default;
2323
- if (!config?.app?.commerce?.api) throw new Error("Invalid config.server.ts: missing app.commerce.api configuration.\nPlease ensure your config.server.ts has the commerce API configuration.");
2324
- const api = config.app.commerce.api;
2325
- const proxyHost = process.env.SCAPI_PROXY_HOST;
2326
- if (!api.shortCode && !proxyHost) throw new Error("Missing shortCode in config.server.ts commerce.api configuration");
2327
- if (!api.organizationId) throw new Error("Missing organizationId in config.server.ts commerce.api configuration");
2328
- if (!api.clientId) throw new Error("Missing clientId in config.server.ts commerce.api configuration");
2329
- return { commerce: { api: {
2330
- shortCode: api.shortCode || "",
2331
- organizationId: api.organizationId,
2332
- clientId: api.clientId,
2333
- proxy: api.proxy || "/mobify/proxy/api",
2334
- proxyHost
2335
- } } };
2336
- }
2337
-
2338
- //#endregion
2339
- //#region src/config.ts
2340
- const SFNEXT_BASE_CARTRIDGE_NAME = "app_storefrontnext_base";
2341
- const SFNEXT_BASE_CARTRIDGE_OUTPUT_DIR = `${SFNEXT_BASE_CARTRIDGE_NAME}/cartridge/experience`;
2342
-
2343
- //#endregion
2344
- //#region src/server/middleware/proxy.ts
2345
- /**
2346
- * Create proxy middleware for Commerce Cloud API
2347
- * Proxies requests from /mobify/proxy/api to the Commerce Cloud API
2348
- */
2349
- function createCommerceProxyMiddleware(config) {
2350
- return createProxyMiddleware({
2351
- target: getCommerceCloudApiUrl(config.commerce.api.shortCode, config.commerce.api.proxyHost),
2352
- changeOrigin: true,
2353
- secure: !config.commerce.api.proxyHost
2354
- });
2355
- }
2356
-
2357
- //#endregion
2358
- //#region src/server/middleware/static.ts
2359
- /**
2360
- * Create static file serving middleware for client assets
2361
- * Serves files from build/client at /mobify/bundle/{BUNDLE_ID}/client/
2362
- */
2363
- function createStaticMiddleware(bundleId, projectDirectory) {
2364
- const bundlePath = getBundlePath(bundleId);
2365
- const clientBuildDir = path$1.join(projectDirectory, "build", "client");
2366
- logger.info(`Serving static assets from ${clientBuildDir} at ${bundlePath}`);
2367
- return express.static(clientBuildDir, { setHeaders: (res) => {
2368
- res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
2369
- res.setHeader("x-local-static-cache-control", "1");
2370
- } });
2371
- }
2372
-
2373
- //#endregion
2374
- //#region src/server/middleware/compression.ts
2375
- /**
2376
- * Parse and validate COMPRESSION_LEVEL environment variable
2377
- * @returns Valid compression level (0-9) or default compression level
2378
- */
2379
- function getCompressionLevel() {
2380
- const raw = process.env.COMPRESSION_LEVEL;
2381
- const DEFAULT = zlib.constants.Z_DEFAULT_COMPRESSION;
2382
- if (raw == null || raw.trim() === "") return DEFAULT;
2383
- const level = Number(raw);
2384
- if (!(Number.isInteger(level) && level >= 0 && level <= 9)) {
2385
- logger.warn(`[compression] Invalid COMPRESSION_LEVEL="${raw}". Using default (${DEFAULT}).`);
2386
- return DEFAULT;
2387
- }
2388
- return level;
2389
- }
2390
- /**
2391
- * Create compression middleware for gzip/brotli compression
2392
- * Used in preview mode to optimize response sizes
2393
- */
2394
- function createCompressionMiddleware() {
2395
- return compression({
2396
- filter: (req, res) => {
2397
- if (req.headers["x-no-compression"]) return false;
2398
- return compression.filter(req, res);
2399
- },
2400
- level: getCompressionLevel()
2401
- });
2402
- }
2403
-
2404
- //#endregion
2405
- //#region src/server/middleware/logging.ts
2406
- /**
2407
- * Patterns for URLs to skip logging (static assets and Vite internals)
2408
- */
2409
- const SKIP_PATTERNS = [
2410
- "/@vite/**",
2411
- "/@id/**",
2412
- "/@fs/**",
2413
- "/@react-router/**",
2414
- "/src/**",
2415
- "/node_modules/**",
2416
- "**/*.js",
2417
- "**/*.css",
2418
- "**/*.ts",
2419
- "**/*.tsx",
2420
- "**/*.js.map",
2421
- "**/*.css.map"
2422
- ];
2423
- /**
2424
- * Create request logging middleware
2425
- * Used in dev and preview modes for request visibility
2426
- */
2427
- function createLoggingMiddleware() {
2428
- morgan.token("status-colored", (req, res) => {
2429
- const status = res.statusCode;
2430
- let color = chalk.green;
2431
- if (status >= 500) color = chalk.red;
2432
- else if (status >= 400) color = chalk.yellow;
2433
- else if (status >= 300) color = chalk.cyan;
2434
- return color(String(status));
2435
- });
2436
- morgan.token("method-colored", (req) => {
2437
- const method = req.method;
2438
- const colors = {
2439
- GET: chalk.green,
2440
- POST: chalk.blue,
2441
- PUT: chalk.yellow,
2442
- DELETE: chalk.red,
2443
- PATCH: chalk.magenta
2444
- };
2445
- return (method && colors[method] || chalk.white)(method);
2446
- });
2447
- return morgan((tokens, req, res) => {
2448
- return [
2449
- tokens["method-colored"](req, res),
2450
- tokens.url(req, res),
2451
- "-",
2452
- tokens["status-colored"](req, res),
2453
- chalk.gray(`(${tokens["response-time"](req, res)}ms)`)
2454
- ].join(" ");
2455
- }, { skip: (req) => {
2456
- return SKIP_PATTERNS.some((pattern) => minimatch(req.url, pattern, { dot: true }));
2457
- } });
2458
- }
2459
-
2460
- //#endregion
2461
- //#region src/server/middleware/host-header.ts
2462
- /**
2463
- * Normalizes the X-Forwarded-Host header to support React Router's CSRF validation features.
2464
- *
2465
- * NOTE: This middleware performs header manipulation as a temporary, internal
2466
- * solution for MRT/Lambda environments. It may be updated or removed if React Router
2467
- * introduces a first-class configuration for validating against forwarded headers.
2468
- *
2469
- * React Router v7.12+ uses the X-Forwarded-Host header (preferring it over Host)
2470
- * to validate request origins for security. In Managed Runtime (MRT) with a vanity
2471
- * domain, the eCDN automatically sets the X-Forwarded-Host to the vanity domain.
2472
- * React Router handles cases where this header contains multiple comma-separated
2473
- * values by prioritizing the first entry.
2474
- *
2475
- * This middleware ensures that X-Forwarded-Host is always present by falling back
2476
- * to a configured public domain if the header is missing (e.g., local development).
2477
- * By only modifying X-Forwarded-Host, we provide a consistent environment for
2478
- * React Router's security checks without modifying the internal 'Host' header,
2479
- * which is required for environment-specific routing logic (e.g., Hybrid Proxy).
2480
- *
2481
- * Priority order:
2482
- * 1. X-Forwarded-Host: Automatically set by eCDN for vanity domains.
2483
- * 2. EXTERNAL_DOMAIN_NAME: Fallback environment variable for the public domain
2484
- * used when no forwarded headers are present (e.g., local development).
2485
- */
2486
- function createHostHeaderMiddleware() {
2487
- return (req, _res, next) => {
2488
- if (!req.get("x-forwarded-host") && process.env.EXTERNAL_DOMAIN_NAME) req.headers["x-forwarded-host"] = process.env.EXTERNAL_DOMAIN_NAME;
2489
- next();
2490
- };
2491
- }
2492
-
2493
- //#endregion
2494
- //#region src/server/utils.ts
2495
- /**
2496
- * Patch React Router build to rewrite asset URLs with the correct bundle path
2497
- * This is needed because the build output uses /assets/ but we preview at /mobify/bundle/{BUNDLE_ID}/client/assets/
2498
- */
2499
- function patchReactRouterBuild(build, bundleId) {
2500
- const bundlePath = getBundlePath(bundleId);
2501
- const basePath = getBasePath();
2502
- const patchedAssetsJson = JSON.stringify(build.assets).replace(/"\/assets\//g, `"${bundlePath}assets/`);
2503
- const newAssets = JSON.parse(patchedAssetsJson);
2504
- return Object.assign({}, build, {
2505
- publicPath: bundlePath,
2506
- assets: newAssets,
2507
- ...basePath && { basename: basePath }
2508
- });
2509
- }
2510
-
2511
- //#endregion
2512
- //#region src/server/modes.ts
2513
- /**
2514
- * Default feature configuration for each server mode
2515
- */
2516
- const ServerModeFeatureMap = {
2517
- development: {
2518
- enableProxy: true,
2519
- enableStaticServing: false,
2520
- enableCompression: false,
2521
- enableLogging: true,
2522
- enableAssetUrlPatching: false
2523
- },
2524
- preview: {
2525
- enableProxy: true,
2526
- enableStaticServing: true,
2527
- enableCompression: true,
2528
- enableLogging: true,
2529
- enableAssetUrlPatching: true
2530
- },
2531
- production: {
2532
- enableProxy: false,
2533
- enableStaticServing: false,
2534
- enableCompression: true,
2535
- enableLogging: true,
2536
- enableAssetUrlPatching: true
2537
- }
2538
- };
2539
-
2540
- //#endregion
2541
- //#region src/otel/mrt-console-span-exporter.ts
2542
- var MrtConsoleSpanExporter = class extends ConsoleSpanExporter {
2543
- export(spans, resultCallback) {
2544
- for (const span of spans) try {
2545
- const ctx = span.spanContext();
2546
- const spanData = {
2547
- traceId: ctx.traceId,
2548
- parentId: span.parentSpanId,
2549
- name: span.name,
2550
- id: ctx.spanId,
2551
- kind: span.kind,
2552
- timestamp: hrTimeToTimeStamp(span.startTime),
2553
- duration: span.duration,
2554
- attributes: span.attributes,
2555
- status: span.status,
2556
- events: span.events,
2557
- links: span.links,
2558
- start_time: span.startTime,
2559
- end_time: span.endTime,
2560
- forwardTrace: process.env.SFNEXT_OTEL_ENABLED === "true"
2561
- };
2562
- console.info(JSON.stringify(spanData));
2563
- } catch {}
2564
- resultCallback({ code: ExportResultCode.SUCCESS });
2565
- }
2566
- };
2567
-
2568
- //#endregion
2569
- //#region src/otel/setup.ts
2570
- const SERVICE_NAME = "storefront-next";
2571
- /**
2572
- * Initializes OpenTelemetry and returns a Tracer from the provider directly.
2573
- *
2574
- * Returns the tracer via `provider.getTracer()` instead of the global
2575
- * `trace.getTracer()` API. In the Vite SSR module runner, the built
2576
- * dist/entry/server.js and the externalized @opentelemetry/sdk-trace-node
2577
- * resolve @opentelemetry/api to different module instances (different paths
2578
- * through pnpm's strict node_modules). Each instance has its own
2579
- * ProxyTracerProvider singleton, so `provider.register()` sets the delegate
2580
- * on sdk-trace-node's API instance while our code's `trace.getTracer()`
2581
- * reads from a separate API instance with no delegate — returning a tracer
2582
- * backed by a bare BasicTracerProvider with NoopSpanProcessor.
2583
- *
2584
- * Getting the tracer directly from the provider bypasses the global registry
2585
- * entirely, guaranteeing the tracer uses our configured span processors.
2586
- */
2587
- let cachedTracer = null;
2588
- const UNDICI_REGISTERED_KEY = Symbol.for("sfnext.otel.undici_registered");
2589
- function initTelemetry() {
2590
- if (cachedTracer) return cachedTracer;
2591
- try {
2592
- const provider = new NodeTracerProvider({ resource: new Resource({ [ATTR_SERVICE_NAME]: SERVICE_NAME }) });
2593
- provider.addSpanProcessor(new SimpleSpanProcessor(new MrtConsoleSpanExporter()));
2594
- provider.register();
2595
- if (!globalThis[UNDICI_REGISTERED_KEY]) {
2596
- globalThis[UNDICI_REGISTERED_KEY] = true;
2597
- registerInstrumentations({
2598
- tracerProvider: provider,
2599
- instrumentations: [new UndiciInstrumentation({ requestHook(span, request) {
2600
- try {
2601
- const method = request.method.toUpperCase();
2602
- const url = `${request.origin}${request.path}`;
2603
- span.updateName(`${method} ${url}`);
2604
- } catch {}
2605
- } })]
2606
- });
2607
- }
2608
- cachedTracer = provider.getTracer(SERVICE_NAME);
2609
- return cachedTracer;
2610
- } catch (error) {
2611
- logger.error("[otel] Failed to initialize OpenTelemetry:", error);
2612
- return null;
2613
- }
2614
- }
2615
-
2616
- //#endregion
2617
- //#region src/otel/express/middleware.ts
2618
- function createOtelExpressMiddleware() {
2619
- const maybeTracer = initTelemetry();
2620
- if (!maybeTracer) return (_req, _res, next) => next();
2621
- const tracer = maybeTracer;
2622
- return (req, res, next) => {
2623
- try {
2624
- const url = new URL(req.originalUrl || req.url, "http://localhost").pathname;
2625
- const method = req.method;
2626
- tracer.startActiveSpan(`[sfnext] server ${method} ${url}`, { attributes: {
2627
- "http.request.method": method,
2628
- "url.path": url
2629
- } }, (serverSpan) => {
2630
- try {
2631
- const spanContext = trace.getSpan(context.active())?.spanContext();
2632
- if (spanContext) {
2633
- const flags = spanContext.traceFlags.toString(16).padStart(2, "0");
2634
- const traceparent = `00-${spanContext.traceId}-${spanContext.spanId}-${flags}`;
2635
- res.setHeader("traceparent", traceparent);
2636
- }
2637
- } catch {}
2638
- const serverCtx = context.active();
2639
- const startTime = performance.now();
2640
- let streamingSpan = null;
2641
- let ttfbMs = 0;
2642
- let ended = false;
2643
- function recordTTFB() {
2644
- if (streamingSpan) return;
2645
- try {
2646
- ttfbMs = Math.round(performance.now() - startTime);
2647
- serverSpan.setAttribute("sfnext.ttfb_ms", ttfbMs);
2648
- streamingSpan = tracer.startSpan(`[sfnext] response streaming ${method} ${url}`, { attributes: {
2649
- "http.request.method": method,
2650
- "url.path": url,
2651
- "sfnext.ttfb_ms": ttfbMs
2652
- } }, serverCtx);
2653
- } catch {}
2654
- }
2655
- const origWriteHead = res.writeHead.bind(res);
2656
- res.writeHead = ((...args) => {
2657
- recordTTFB();
2658
- return origWriteHead(...args);
2659
- });
2660
- const origWrite = res.write.bind(res);
2661
- res.write = ((...args) => {
2662
- recordTTFB();
2663
- return origWrite(...args);
2664
- });
2665
- function endSpans() {
2666
- if (ended) return;
2667
- ended = true;
2668
- try {
2669
- const totalMs = Math.round(performance.now() - startTime);
2670
- const statusCode = res.statusCode;
2671
- if (streamingSpan) {
2672
- streamingSpan.setAttribute("http.streaming_duration_ms", totalMs - ttfbMs);
2673
- streamingSpan.setAttribute("http.response.status_code", statusCode);
2674
- if (statusCode >= 500) streamingSpan.setStatus({ code: SpanStatusCode.ERROR });
2675
- streamingSpan.end();
2676
- }
2677
- serverSpan.setAttribute("http.response.status_code", statusCode);
2678
- serverSpan.setAttribute("http.total_duration_ms", totalMs);
2679
- if (statusCode >= 500) serverSpan.setStatus({ code: SpanStatusCode.ERROR });
2680
- serverSpan.end();
2681
- } catch {}
2682
- }
2683
- res.once("close", endSpans);
2684
- res.once("finish", endSpans);
2685
- next();
2686
- });
2687
- } catch {
2688
- next();
2689
- }
2690
- };
2691
- }
2692
-
2693
- //#endregion
2694
- //#region src/server/handlers/health-check.ts
2695
- const DEFAULT_HEALTH_DESCRIPTION = "storefront-next-dev server health";
2696
- const PACKAGE_JSON_NAME = "package.json";
2697
- const RUNTIME_PACKAGE_NAME = "@salesforce/storefront-next-runtime";
2698
- const DEV_PACKAGE_NAME = "@salesforce/storefront-next-dev";
2699
- const BUILD_FOLDER_NAME = "build";
2700
- const LOCAL_BUNDLE_ID = "local";
2701
- const HEALTH_ENDPOINT_PATH = "/sfdc-health";
2702
- /**
2703
- * Reads a package.json file and returns selected metadata.
2704
- *
2705
- * @param path - Absolute path to a package.json file
2706
- * @returns Parsed metadata, or null if missing/unreadable
2707
- *
2708
- * @example
2709
- * ```ts
2710
- * const metadata = readPackageMetadata('/app/package.json');
2711
- * console.log(metadata?.version);
2712
- * ```
2713
- */
2714
- function readPackageMetadata(path$2) {
2715
- if (!existsSync$1(path$2)) return null;
2716
- try {
2717
- return JSON.parse(readFileSync$1(path$2, "utf8"));
2718
- } catch (error) {
2719
- logger.debug(`Health check: failed to parse package.json at ${path$2}`, error);
2720
- return null;
2721
- }
2722
- }
2723
- /**
2724
- * Creates an Express handler that returns Health+JSON for the project.
2725
- *
2726
- * @param options - Handler options
2727
- * @returns Express request handler for the health endpoint
2728
- *
2729
- * @example
2730
- * ```ts
2731
- * app.get(HEALTH_ENDPOINT_PATH, createHealthCheckHandler({
2732
- * projectDirectory: process.cwd(),
2733
- * bundleId: LOCAL_BUNDLE_ID,
2734
- * }));
2735
- * ```
2736
- */
2737
- function createHealthCheckHandler(options) {
2738
- const { projectDirectory, bundleId } = options;
2739
- const projectPackage = readPackageMetadata(bundleId === LOCAL_BUNDLE_ID ? resolve(projectDirectory, PACKAGE_JSON_NAME) : resolve(projectDirectory, BUILD_FOLDER_NAME, PACKAGE_JSON_NAME));
2740
- const allDependencies = {
2741
- ...projectPackage?.dependencies,
2742
- ...projectPackage?.devDependencies
2743
- };
2744
- const devVersion = allDependencies?.[DEV_PACKAGE_NAME];
2745
- const runtimeVersion = allDependencies?.[RUNTIME_PACKAGE_NAME];
2746
- const notes = [devVersion ? `Built using ${DEV_PACKAGE_NAME}@${devVersion}.` : null, runtimeVersion ? `Running ${RUNTIME_PACKAGE_NAME}@${runtimeVersion}.` : null].filter(Boolean);
2747
- return (_req, res) => {
2748
- const healthResponse = {
2749
- status: "pass",
2750
- version: projectPackage?.version,
2751
- bundleId,
2752
- description: projectPackage?.description ?? DEFAULT_HEALTH_DESCRIPTION,
2753
- notes: notes.length > 0 ? notes : void 0
2754
- };
2755
- res.status(200).type("application/health+json").json(healthResponse);
2756
- };
2757
- }
2758
-
2759
- //#endregion
2760
- //#region src/server/index.ts
2761
- /** Relative path to the middleware registry TypeScript source (development). Must match appDirectory + server dir + filename used by buildMiddlewareRegistry plugin. */
2762
- const RELATIVE_MIDDLEWARE_REGISTRY_SOURCE = "src/server/middleware-registry.ts";
2763
- /** Extensions to try for the built middlewares module (ESM first, then CJS for backwards compatibility). */
2764
- const MIDDLEWARE_REGISTRY_BUILT_EXTENSIONS = [
2765
- ".mjs",
2766
- ".js",
2767
- ".cjs"
2768
- ];
2769
- /** All paths to try when loading the built middlewares (base + extension). */
2770
- const RELATIVE_MIDDLEWARE_REGISTRY_BUILT_PATHS = ["bld/server/middleware-registry", "build/server/middleware-registry"].flatMap((base) => MIDDLEWARE_REGISTRY_BUILT_EXTENSIONS.map((ext) => `${base}${ext}`));
2771
- const DEFAULT_BUNDLE_ID = "local";
2772
- /**
2773
- * Create a unified Express server for development, preview, or production mode
2774
- */
2775
- async function createServer(options) {
2776
- const { mode, projectDirectory = process.cwd(), config: providedConfig, vite, build, streaming = false, enableProxy = ServerModeFeatureMap[mode].enableProxy, enableStaticServing = ServerModeFeatureMap[mode].enableStaticServing, enableCompression = ServerModeFeatureMap[mode].enableCompression, enableLogging = ServerModeFeatureMap[mode].enableLogging, enableAssetUrlPatching = ServerModeFeatureMap[mode].enableAssetUrlPatching } = options;
2777
- if (mode === "development" && !vite) throw new Error("Vite dev server instance is required for development mode");
2778
- if ((mode === "preview" || mode === "production") && !build) throw new Error("React Router server build is required for preview/production mode");
2779
- const config = providedConfig ?? loadConfigFromEnv();
2780
- const bundleId = process.env.BUNDLE_ID ?? DEFAULT_BUNDLE_ID;
2781
- const app = express();
2782
- app.disable("x-powered-by");
2783
- if (process.env.SFNEXT_OTEL_ENABLED === "true") app.use(createOtelExpressMiddleware());
2784
- app.get(HEALTH_ENDPOINT_PATH, createHealthCheckHandler({
2785
- projectDirectory,
2786
- bundleId
2787
- }));
2788
- if (enableLogging) app.use(createLoggingMiddleware());
2789
- if (enableCompression && !streaming) app.use(createCompressionMiddleware());
2790
- if (enableStaticServing && build) {
2791
- const bundlePath = getBundlePath(bundleId);
2792
- app.use(bundlePath, createStaticMiddleware(bundleId, projectDirectory));
2793
- }
2794
- let registry = null;
2795
- if (mode === "development") {
2796
- const middlewareRegistryPath = resolve(projectDirectory, RELATIVE_MIDDLEWARE_REGISTRY_SOURCE);
2797
- if (existsSync$1(middlewareRegistryPath)) registry = await importTypescript(middlewareRegistryPath, { projectDirectory });
2798
- } else {
2799
- const possiblePaths = RELATIVE_MIDDLEWARE_REGISTRY_BUILT_PATHS.map((p) => resolve(projectDirectory, p));
2800
- let builtRegistryPath = null;
2801
- for (const path$2 of possiblePaths) if (existsSync$1(path$2)) {
2802
- builtRegistryPath = path$2;
2803
- break;
2804
- }
2805
- if (builtRegistryPath) registry = await import(pathToFileURL$1(builtRegistryPath).href);
2806
- }
2807
- if (registry?.customMiddlewares && Array.isArray(registry.customMiddlewares)) registry.customMiddlewares.forEach((entry) => {
2808
- app.use(entry.handler);
2809
- });
2810
- if (mode === "development" && vite) app.use(vite.middlewares);
2811
- if (enableProxy) app.use(config.commerce.api.proxy, createCommerceProxyMiddleware(config));
2812
- const basePath = getBasePath();
2813
- if (basePath) app.use((req, res, next) => {
2814
- if (req.path.startsWith(`${basePath}/`) || req.path === basePath) return next();
2815
- if (req.path.startsWith("/mobify/")) return next();
2816
- res.redirect(`${basePath}${req.originalUrl}`);
2817
- });
2818
- app.use(createHostHeaderMiddleware());
2819
- app.all("*splat", await createSSRHandler(mode, bundleId, vite, build, enableAssetUrlPatching));
2820
- return app;
2821
- }
2822
- /**
2823
- * Create the SSR request handler based on mode
2824
- */
2825
- async function createSSRHandler(mode, bundleId, vite, build, enableAssetUrlPatching) {
2826
- if (mode === "development" && vite) {
2827
- const { isRunnableDevEnvironment } = await import("vite");
2828
- return async (req, res, next) => {
2829
- try {
2830
- const ssrEnvironment = vite.environments.ssr;
2831
- if (!isRunnableDevEnvironment(ssrEnvironment)) {
2832
- next(/* @__PURE__ */ new Error("SSR environment is not runnable. Please ensure:\n 1. \"@salesforce/storefront-next-dev\" plugin is added to vite.config.ts\n 2. React Router config uses the Storefront Next preset"));
2833
- return;
2834
- }
2835
- await createRequestHandler({
2836
- build: await ssrEnvironment.runner.import("virtual:react-router/server-build"),
2837
- mode: process.env.NODE_ENV
2838
- })(req, res, next);
2839
- } catch (error) {
2840
- vite.ssrFixStacktrace(error);
2841
- next(error);
2842
- }
2843
- };
2844
- } else if (build) {
2845
- let patchedBuild = build;
2846
- if (enableAssetUrlPatching) patchedBuild = patchReactRouterBuild(build, bundleId);
2847
- const requestHandlerMode = process.env.NODE_OPTIONS?.includes("--enable-source-maps") ? "development" : process.env.NODE_ENV;
2848
- return createRequestHandler({
2849
- build: patchedBuild,
2850
- mode: requestHandlerMode
2851
- });
2852
- } else throw new Error("Invalid server configuration: no vite or build provided");
2853
- }
2854
-
2855
- //#endregion
2856
- //#region src/extensibility/path-util.ts
2857
- const FILE_EXTENSIONS = [
2858
- ".tsx",
2859
- ".ts",
2860
- ".d.ts"
2861
- ];
2862
- function isSupportedFileExtension(fileName) {
2863
- return FILE_EXTENSIONS.some((ext) => fileName.endsWith(ext));
2864
- }
2865
-
2866
- //#endregion
2867
- //#region src/extensibility/trim-extensions.ts
2868
- const SINGLE_LINE_MARKER = "@sfdc-extension-line";
2869
- const BLOCK_MARKER_START = "@sfdc-extension-block-start";
2870
- const BLOCK_MARKER_END = "@sfdc-extension-block-end";
2871
- const FILE_MARKER = "@sfdc-extension-file";
2872
- function trimExtensions(directory, selectedExtensions, extensionConfig) {
2873
- const startTime = Date.now();
2874
- const configuredExtensions = extensionConfig?.extensions || {};
2875
- const extensions = {};
2876
- Object.keys(configuredExtensions).forEach((targetKey) => {
2877
- extensions[targetKey] = Boolean(selectedExtensions?.[targetKey]) || false;
2878
- });
2879
- if (Object.keys(extensions).length === 0) {
2880
- logger.debug("No targets found, skipping trim");
2881
- return;
2882
- }
2883
- const processDirectory = (dir) => {
2884
- fs$1.readdirSync(dir).forEach((file) => {
2885
- const filePath = path$1.join(dir, file);
2886
- const stats = fs$1.statSync(filePath);
2887
- if (!filePath.includes("node_modules")) {
2888
- if (stats.isDirectory()) processDirectory(filePath);
2889
- else if (isSupportedFileExtension(file)) processFile(filePath, extensions);
2890
- }
2891
- });
2892
- };
2893
- processDirectory(directory);
2894
- if (extensionConfig?.extensions) {
2895
- deleteExtensionFolders(directory, extensions, extensionConfig);
2896
- updateExtensionConfig(directory, extensions);
2897
- }
2898
- const endTime = Date.now();
2899
- logger.debug(`Trim extensions took ${endTime - startTime}ms`);
2900
- }
2901
- /**
2902
- * Update the extension config file to only include the selected extensions.
2903
- * @param projectDirectory - The project directory
2904
- * @param extensionSelections - The selected extensions
2905
- */
2906
- function updateExtensionConfig(projectDirectory, extensionSelections) {
2907
- const extensionConfigPath = path$1.join(projectDirectory, "src", "extensions", "config.json");
2908
- const extensionConfig = JSON.parse(fs$1.readFileSync(extensionConfigPath, "utf8"));
2909
- Object.keys(extensionConfig.extensions).forEach((extensionKey) => {
2910
- if (!extensionSelections[extensionKey]) delete extensionConfig.extensions[extensionKey];
2911
- });
2912
- fs$1.writeFileSync(extensionConfigPath, JSON.stringify({ extensions: extensionConfig.extensions }, null, 4), "utf8");
2913
- }
2914
- /**
2915
- * Process a file to trim extension-specific code based on markers.
2916
- * @param filePath - The file path to process
2917
- * @param extensions - The extension selections
2918
- */
2919
- function processFile(filePath, extensions) {
2920
- const source = fs$1.readFileSync(filePath, "utf-8");
2921
- if (source.includes(FILE_MARKER)) {
2922
- const markerLine = source.split("\n").find((line) => line.includes(FILE_MARKER));
2923
- const extMatch = Object.keys(extensions).find((ext) => markerLine.includes(ext));
2924
- if (!extMatch) logger.warn(`File ${filePath} is marked with ${markerLine} but it does not match any known extensions`);
2925
- else if (extensions[extMatch] === false) {
2926
- try {
2927
- fs$1.unlinkSync(filePath);
2928
- logger.debug(`Deleted file ${filePath}`);
2929
- } catch (e) {
2930
- const error = e;
2931
- logger.error(`Error deleting file ${filePath}: ${error.message}`);
2932
- throw e;
2933
- }
2934
- return;
2935
- }
2936
- }
2937
- const extKeys = Object.keys(extensions);
2938
- if (new RegExp(extKeys.join("|"), "g").test(source)) {
2939
- const lines = source.split("\n");
2940
- const newLines = [];
2941
- const blockMarkers = [];
2942
- let skippingBlock = false;
2943
- let i = 0;
2944
- while (i < lines.length) {
2945
- const line = lines[i];
2946
- if (line.includes(SINGLE_LINE_MARKER)) {
2947
- const matchingExtension = Object.keys(extensions).find((extension) => line.includes(extension));
2948
- if (matchingExtension && extensions[matchingExtension] === false) {
2949
- i += 2;
2950
- continue;
2951
- }
2952
- } else if (line.includes(BLOCK_MARKER_START)) {
2953
- const matchingExtension = Object.keys(extensions).find((extension) => line.includes(extension));
2954
- if (matchingExtension) {
2955
- blockMarkers.push({
2956
- extension: matchingExtension,
2957
- line: i
2958
- });
2959
- skippingBlock = extensions[matchingExtension] === false;
2960
- } else logger.warn(`Unknown marker found in ${filePath} at line ${i}: \n${line}`);
2961
- } else if (line.includes(BLOCK_MARKER_END)) {
2962
- if (Object.keys(extensions).find((extension) => line.includes(extension))) {
2963
- const extension = Object.keys(extensions).find((p) => line.includes(p));
2964
- if (blockMarkers.length === 0) throw new Error(`Block marker mismatch in ${filePath}, encountered end marker ${extension} without a matching start marker at line ${i}:\n${lines[i]}`);
2965
- const startMarker = blockMarkers.pop();
2966
- if (!extension || startMarker.extension !== extension) throw new Error(`Block marker mismatch in ${filePath}, expected end marker for ${startMarker.extension} but got ${extension} at line ${i}:\n${lines[i]}`);
2967
- if (extensions[extension] === false) {
2968
- skippingBlock = false;
2969
- i++;
2970
- continue;
2971
- }
2972
- }
2973
- }
2974
- if (!skippingBlock) newLines.push(line);
2975
- i++;
2976
- }
2977
- if (blockMarkers.length > 0) throw new Error(`Unclosed end marker found in ${filePath}: ${blockMarkers[blockMarkers.length - 1].extension}`);
2978
- const newSource = newLines.join("\n");
2979
- if (newSource !== source) try {
2980
- fs$1.writeFileSync(filePath, newSource);
2981
- logger.debug(`Updated file ${filePath}`);
2982
- } catch (e) {
2983
- const error = e;
2984
- logger.error(`Error updating file ${filePath}: ${error.message}`);
2985
- throw e;
2986
- }
2987
- }
2988
- }
2989
- /**
2990
- * Delete extension folders for disabled extensions.
2991
- * @param projectRoot - The project root directory
2992
- * @param extensions - The extension selections
2993
- * @param extensionConfig - The extension configuration
2994
- */
2995
- function deleteExtensionFolders(projectRoot, extensions, extensionConfig) {
2996
- const extensionsDir = path$1.join(projectRoot, "src", "extensions");
2997
- if (!fs$1.existsSync(extensionsDir)) return;
2998
- const configuredExtensions = extensionConfig.extensions;
2999
- Object.keys(extensions).filter((ext) => extensions[ext] === false).forEach((extKey) => {
3000
- const extensionMeta = configuredExtensions[extKey];
3001
- if (extensionMeta?.folder) {
3002
- const extensionFolderPath = path$1.join(extensionsDir, extensionMeta.folder);
3003
- if (fs$1.existsSync(extensionFolderPath)) try {
3004
- fs$1.rmSync(extensionFolderPath, {
3005
- recursive: true,
3006
- force: true
3007
- });
3008
- logger.debug(`Deleted extension folder: ${extensionFolderPath}`);
3009
- } catch (err) {
3010
- const error = err;
3011
- if (error.code === "EPERM") logger.error(`Permission denied - cannot delete ${extensionFolderPath}. You may need to run with sudo or check permissions.`);
3012
- else logger.error(`Error deleting ${extensionFolderPath}: ${error.message}`);
3013
- }
3014
- }
3015
- });
3016
- }
3017
-
3018
- //#endregion
3019
- //#region src/cartridge-services/react-router-config.ts
3020
- let isCliAvailable = null;
3021
- function checkReactRouterCli(projectDirectory) {
3022
- if (isCliAvailable !== null) return isCliAvailable;
3023
- try {
3024
- execSync("react-router --version", {
3025
- cwd: projectDirectory,
3026
- env: npmRunPathEnv(),
3027
- stdio: "pipe"
3028
- });
3029
- isCliAvailable = true;
3030
- } catch {
3031
- isCliAvailable = false;
3032
- }
3033
- return isCliAvailable;
3034
- }
3035
- /**
3036
- * Get the fully resolved routes from React Router by invoking its CLI.
3037
- * This ensures we get the exact same route resolution as React Router uses internally,
3038
- * including all presets, file-system routes, and custom route configurations.
3039
- * @param projectDirectory - The project root directory
3040
- * @returns Array of resolved route config entries
3041
- * @example
3042
- * const routes = getReactRouterRoutes('/path/to/project');
3043
- * // Returns the same structure as `react-router routes --json`
3044
- */
3045
- function getReactRouterRoutes(projectDirectory) {
3046
- if (!checkReactRouterCli(projectDirectory)) throw new Error("React Router CLI is not available. Please make sure @react-router/dev is installed and accessible.");
3047
- const tempFile = join(tmpdir(), `react-router-routes-${randomUUID()}.json`);
3048
- try {
3049
- execSync(`react-router routes --json > "${tempFile}"`, {
3050
- cwd: projectDirectory,
3051
- env: npmRunPathEnv(),
3052
- encoding: "utf-8",
3053
- stdio: [
3054
- "pipe",
3055
- "pipe",
3056
- "pipe"
3057
- ]
3058
- });
3059
- const output = readFileSync$1(tempFile, "utf-8");
3060
- return JSON.parse(output);
3061
- } catch (error) {
3062
- throw new Error(`Failed to get routes from React Router CLI: ${error.message}`);
3063
- } finally {
3064
- try {
3065
- if (existsSync$1(tempFile)) unlinkSync(tempFile);
3066
- } catch {}
3067
- }
3068
- }
3069
- /**
3070
- * Convert a file path to its corresponding route path using React Router's CLI.
3071
- * This ensures we get the exact same route resolution as React Router uses internally.
3072
- * @param filePath - Absolute path to the route file
3073
- * @param projectRoot - The project root directory
3074
- * @returns The route path (e.g., '/cart', '/product/:productId')
3075
- * @example
3076
- * const route = filePathToRoute('/path/to/project/src/routes/_app.cart.tsx', '/path/to/project');
3077
- * // Returns: '/cart'
3078
- */
3079
- function filePathToRoute(filePath, projectRoot) {
3080
- const filePathPosix = filePath.replace(/\\/g, "/");
3081
- const canonicalRoutes = flattenRoutes(getReactRouterRoutes(projectRoot)).filter((route) => !route.id.endsWith("--root-duplicate"));
3082
- for (const route of canonicalRoutes) {
3083
- const routeFilePosix = route.file.replace(/\\/g, "/");
3084
- const routeFileNormalized = routeFilePosix.replace(/^\.\//, "");
3085
- if (filePathPosix.endsWith(routeFilePosix) || filePathPosix.endsWith(`/${routeFilePosix}`) || filePathPosix.endsWith(routeFileNormalized) || filePathPosix.endsWith(`/${routeFileNormalized}`)) return route.path;
3086
- }
3087
- logger.warn(`Could not find route for file: ${filePath}`);
3088
- return "/unknown";
3089
- }
3090
- /**
3091
- * Flatten a nested route tree into a flat array with computed paths.
3092
- * Each route will have its full path computed from parent paths.
3093
- * @param routes - The nested route config entries
3094
- * @param parentPath - The parent path prefix (used internally for recursion)
3095
- * @returns Flat array of routes with their full paths
3096
- */
3097
- function flattenRoutes(routes, parentPath = "") {
3098
- const result = [];
3099
- for (const route of routes) {
3100
- let fullPath;
3101
- if (route.index) fullPath = parentPath || "/";
3102
- else if (route.path) {
3103
- const pathSegment = route.path.startsWith("/") ? route.path : `/${route.path}`;
3104
- fullPath = parentPath ? `${parentPath}${pathSegment}`.replace(/\/+/g, "/") : pathSegment;
3105
- } else fullPath = parentPath || "/";
3106
- if (route.id) result.push({
3107
- id: route.id,
3108
- path: fullPath,
3109
- file: route.file,
3110
- index: route.index
3111
- });
3112
- if (route.children && route.children.length > 0) {
3113
- const childPath = route.path ? fullPath : parentPath;
3114
- result.push(...flattenRoutes(route.children, childPath));
3115
- }
3116
- }
3117
- return result;
3118
- }
3119
-
3120
- //#endregion
3121
- //#region src/cartridge-services/generate-cartridge.ts
3122
- const SKIP_DIRECTORIES = [
3123
- "build",
3124
- "dist",
3125
- "node_modules",
3126
- ".git",
3127
- ".next",
3128
- "coverage"
3129
- ];
3130
- const DEFAULT_COMPONENT_GROUP = "storefrontnext_base";
3131
- const ARCH_TYPE_HEADLESS = "headless";
3132
- const VALID_ATTRIBUTE_TYPES = [
3133
- "string",
3134
- "text",
3135
- "markup",
3136
- "integer",
3137
- "boolean",
3138
- "product",
3139
- "category",
3140
- "file",
3141
- "page",
3142
- "image",
3143
- "url",
3144
- "enum",
3145
- "custom",
3146
- "cms_record"
3147
- ];
3148
- const TYPE_MAPPING = {
3149
- String: "string",
3150
- string: "string",
3151
- Number: "integer",
3152
- number: "integer",
3153
- Boolean: "boolean",
3154
- boolean: "boolean",
3155
- Date: "string",
3156
- URL: "url",
3157
- CMSRecord: "cms_record"
3158
- };
3159
- function resolveAttributeType(decoratorType, tsMorphType, fieldName) {
3160
- if (decoratorType) {
3161
- if (!VALID_ATTRIBUTE_TYPES.includes(decoratorType)) {
3162
- logger.error(`Invalid attribute type '${decoratorType}' for field '${fieldName || "unknown"}'. Valid types are: ${VALID_ATTRIBUTE_TYPES.join(", ")}`);
3163
- process.exit(1);
3164
- }
3165
- return decoratorType;
3166
- }
3167
- if (tsMorphType && TYPE_MAPPING[tsMorphType]) return TYPE_MAPPING[tsMorphType];
3168
- return "string";
3169
- }
3170
- function toHumanReadableName(fieldName) {
3171
- return fieldName.replace(/([A-Z])/g, " $1").replace(/^./, (str) => str.toUpperCase()).trim();
3172
- }
3173
- function toCamelCaseFileName(name) {
3174
- if (!/[\s-]/.test(name)) return name;
3175
- return name.split(/[\s-]+/).map((word, index) => {
3176
- if (index === 0) return word.toLowerCase();
3177
- return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
3178
- }).join("");
3179
- }
3180
- function getTypeFromTsMorph(property, _sourceFile) {
3181
- try {
3182
- const typeNode = property.getTypeNode();
3183
- if (typeNode) return typeNode.getText().split("|")[0].split("&")[0].trim();
3184
- } catch {}
3185
- return "string";
3186
- }
3187
- /**
3188
- * Resolve a variable's initializer expression from the same source file,
3189
- * unwrapping `as const` type assertions.
3190
- */
3191
- function resolveVariableInitializer(sourceFile, name) {
3192
- const varDecl = sourceFile.getVariableDeclaration(name);
3193
- if (!varDecl) return void 0;
3194
- let initializer = varDecl.getInitializer();
3195
- if (initializer && Node.isAsExpression(initializer)) initializer = initializer.getExpression();
3196
- return initializer;
3197
- }
3198
- /**
3199
- * Check whether an AST node is a type that `parseExpression` can resolve to a
3200
- * concrete JS value (as opposed to falling through to `getText()`).
3201
- */
3202
- function isResolvableLiteral(node) {
3203
- return Node.isStringLiteral(node) || Node.isNumericLiteral(node) || Node.isTrueLiteral(node) || Node.isFalseLiteral(node) || Node.isObjectLiteralExpression(node) || Node.isArrayLiteralExpression(node);
3204
- }
3205
- var UnresolvedConstantReferenceError = class extends Error {
3206
- constructor(reference) {
3207
- super(`Cannot resolve constant reference '${reference}'. Ensure the variable is declared in the same file as a literal value.`);
3208
- this.name = "UnresolvedConstantReferenceError";
3209
- }
3210
- };
3211
- function parseExpression(expression) {
3212
- if (Node.isStringLiteral(expression)) return expression.getLiteralValue();
3213
- else if (Node.isNumericLiteral(expression)) return expression.getLiteralValue();
3214
- else if (Node.isTrueLiteral(expression)) return true;
3215
- else if (Node.isFalseLiteral(expression)) return false;
3216
- else if (Node.isObjectLiteralExpression(expression)) return parseNestedObject(expression);
3217
- else if (Node.isArrayLiteralExpression(expression)) return parseArrayLiteral(expression);
3218
- else if (Node.isPropertyAccessExpression(expression)) {
3219
- const obj = expression.getExpression();
3220
- const propName = expression.getName();
3221
- if (Node.isIdentifier(obj)) {
3222
- const resolved = resolveVariableInitializer(expression.getSourceFile(), obj.getText());
3223
- if (resolved && Node.isObjectLiteralExpression(resolved)) {
3224
- const prop = resolved.getProperty(propName);
3225
- if (prop && Node.isPropertyAssignment(prop)) {
3226
- const propInit = prop.getInitializer();
3227
- if (propInit) return parseExpression(propInit);
3228
- }
3229
- }
3230
- throw new UnresolvedConstantReferenceError(expression.getText());
3231
- }
3232
- return expression.getText();
3233
- } else if (Node.isIdentifier(expression)) {
3234
- const resolved = resolveVariableInitializer(expression.getSourceFile(), expression.getText());
3235
- if (resolved && isResolvableLiteral(resolved)) return parseExpression(resolved);
3236
- return expression.getText();
3237
- } else return expression.getText();
3238
- }
3239
- function parseNestedObject(objectLiteral) {
3240
- const result = {};
3241
- try {
3242
- const properties = objectLiteral.getProperties();
3243
- for (const property of properties) if (Node.isPropertyAssignment(property)) {
3244
- const name = property.getName();
3245
- const initializer = property.getInitializer();
3246
- if (initializer) result[name] = parseExpression(initializer);
3247
- }
3248
- } catch (error) {
3249
- logger.warn(`Could not parse nested object: ${error.message}`);
3250
- return result;
3251
- }
3252
- return result;
3253
- }
3254
- function parseArrayLiteral(arrayLiteral) {
3255
- const result = [];
3256
- try {
3257
- const elements = arrayLiteral.getElements();
3258
- for (const element of elements) result.push(parseExpression(element));
3259
- } catch (error) {
3260
- logger.warn(`Could not parse array literal: ${error.message}`);
3261
- }
3262
- return result;
3263
- }
3264
- function parseDecoratorArgs(decorator) {
3265
- const result = {};
3266
- try {
3267
- const args = decorator.getArguments();
3268
- if (args.length === 0) return result;
3269
- const firstArg = args[0];
3270
- if (Node.isObjectLiteralExpression(firstArg)) {
3271
- const properties = firstArg.getProperties();
3272
- for (const property of properties) if (Node.isPropertyAssignment(property)) {
3273
- const name = property.getName();
3274
- const initializer = property.getInitializer();
3275
- if (initializer) result[name] = parseExpression(initializer);
3276
- }
3277
- } else if (Node.isStringLiteral(firstArg)) {
3278
- result.id = parseExpression(firstArg);
3279
- if (args.length > 1) {
3280
- const secondArg = args[1];
3281
- if (Node.isObjectLiteralExpression(secondArg)) {
3282
- const properties = secondArg.getProperties();
3283
- for (const property of properties) if (Node.isPropertyAssignment(property)) {
3284
- const name = property.getName();
3285
- const initializer = property.getInitializer();
3286
- if (initializer) result[name] = parseExpression(initializer);
3287
- }
3288
- }
3289
- }
3290
- }
3291
- return result;
3292
- } catch (error) {
3293
- if (error instanceof UnresolvedConstantReferenceError) throw error;
3294
- logger.warn(`Could not parse decorator arguments: ${error.message}`);
3295
- return result;
3296
- }
3297
- }
3298
- function extractAttributesFromSource(sourceFile, className) {
3299
- const attributes = [];
3300
- try {
3301
- const classDeclaration = sourceFile.getClass(className);
3302
- if (!classDeclaration) return attributes;
3303
- const properties = classDeclaration.getProperties();
3304
- for (const property of properties) {
3305
- const attributeDecorator = property.getDecorator("AttributeDefinition");
3306
- if (!attributeDecorator) continue;
3307
- const fieldName = property.getName();
3308
- const config = parseDecoratorArgs(attributeDecorator);
3309
- const isRequired = !property.hasQuestionToken();
3310
- const inferredType = config.type || getTypeFromTsMorph(property, sourceFile);
3311
- const attribute = {
3312
- id: config.id || fieldName,
3313
- name: config.name || toHumanReadableName(fieldName),
3314
- type: resolveAttributeType(config.type, inferredType, fieldName),
3315
- required: config.required !== void 0 ? config.required : isRequired,
3316
- description: config.description || `Field: ${fieldName}`
3317
- };
3318
- if (config.values) attribute.values = config.values;
3319
- if (config.defaultValue !== void 0) attribute.default_value = config.defaultValue;
3320
- attributes.push(attribute);
3321
- }
3322
- } catch (error) {
3323
- if (error instanceof UnresolvedConstantReferenceError) throw error;
3324
- logger.warn(`Could not extract attributes from class ${className}: ${error.message}`);
3325
- }
3326
- return attributes;
3327
- }
3328
- function normalizeComponentTypeId(typeId, defaultGroup) {
3329
- return typeId.includes(".") ? typeId : `${defaultGroup}.${typeId}`;
3330
- }
3331
- function extractRegionDefinitionsFromSource(sourceFile, className, defaultComponentGroup = DEFAULT_COMPONENT_GROUP) {
3332
- const regionDefinitions = [];
3333
- try {
3334
- const classDeclaration = sourceFile.getClass(className);
3335
- if (!classDeclaration) return regionDefinitions;
3336
- const classRegionDecorator = classDeclaration.getDecorator("RegionDefinition");
3337
- if (classRegionDecorator) {
3338
- const args = classRegionDecorator.getArguments();
3339
- if (args.length > 0) {
3340
- const firstArg = args[0];
3341
- if (Node.isArrayLiteralExpression(firstArg)) {
3342
- const elements = firstArg.getElements();
3343
- for (const element of elements) if (Node.isObjectLiteralExpression(element)) {
3344
- const regionConfig = parseDecoratorArgs({ getArguments: () => [element] });
3345
- const regionDefinition = {
3346
- id: regionConfig.id || "region",
3347
- name: regionConfig.name || "Region"
3348
- };
3349
- if (regionConfig.componentTypes) regionDefinition.component_types = regionConfig.componentTypes;
3350
- if (Array.isArray(regionConfig.componentTypeInclusions)) regionDefinition.component_type_inclusions = regionConfig.componentTypeInclusions.map((incl) => ({ type_id: normalizeComponentTypeId(String(incl), defaultComponentGroup) }));
3351
- if (Array.isArray(regionConfig.componentTypeExclusions)) regionDefinition.component_type_exclusions = regionConfig.componentTypeExclusions.map((excl) => ({ type_id: normalizeComponentTypeId(String(excl), defaultComponentGroup) }));
3352
- if (regionConfig.maxComponents !== void 0) regionDefinition.max_components = regionConfig.maxComponents;
3353
- if (regionConfig.minComponents !== void 0) regionDefinition.min_components = regionConfig.minComponents;
3354
- if (regionConfig.allowMultiple !== void 0) regionDefinition.allow_multiple = regionConfig.allowMultiple;
3355
- if (regionConfig.defaultComponentConstructors) regionDefinition.default_component_constructors = regionConfig.defaultComponentConstructors;
3356
- regionDefinitions.push(regionDefinition);
3357
- }
3358
- }
3359
- }
3360
- }
3361
- } catch (error) {
3362
- logger.warn(`Warning: Could not extract region definitions from class ${className}: ${error.message}`);
3363
- }
3364
- return regionDefinitions;
3365
- }
3366
- async function processComponentFile(filePath, _projectRoot) {
3367
- try {
3368
- const content = await readFile(filePath, "utf-8");
3369
- const components = [];
3370
- if (!content.includes("@Component")) return components;
3371
- try {
3372
- const sourceFile = new Project({
3373
- useInMemoryFileSystem: true,
3374
- skipAddingFilesFromTsConfig: true
3375
- }).createSourceFile(filePath, content);
3376
- const classes = sourceFile.getClasses();
3377
- for (const classDeclaration of classes) {
3378
- const componentDecorator = classDeclaration.getDecorator("Component");
3379
- if (!componentDecorator) continue;
3380
- const className = classDeclaration.getName();
3381
- if (!className) continue;
3382
- const componentConfig = parseDecoratorArgs(componentDecorator);
3383
- const componentGroup = String(componentConfig.group || DEFAULT_COMPONENT_GROUP);
3384
- const attributes = extractAttributesFromSource(sourceFile, className);
3385
- const regionDefinitions = extractRegionDefinitionsFromSource(sourceFile, className, componentGroup);
3386
- const componentMetadata = {
3387
- typeId: componentConfig.id || className.toLowerCase(),
3388
- name: componentConfig.name || toHumanReadableName(className),
3389
- group: componentGroup,
3390
- description: componentConfig.description || `Custom component: ${className}`,
3391
- regionDefinitions,
3392
- attributes
3393
- };
3394
- components.push(componentMetadata);
3395
- }
3396
- } catch (error) {
3397
- if (error instanceof UnresolvedConstantReferenceError) throw error;
3398
- logger.warn(`Could not process file ${filePath}:`, error.message);
3399
- }
3400
- return components;
3401
- } catch (error) {
3402
- if (error instanceof UnresolvedConstantReferenceError) throw error;
3403
- logger.warn(`Could not read file ${filePath}:`, error.message);
3404
- return [];
3405
- }
3406
- }
3407
- async function processPageTypeFile(filePath, projectRoot) {
3408
- try {
3409
- const content = await readFile(filePath, "utf-8");
3410
- const pageTypes = [];
3411
- if (!content.includes("@PageType")) return pageTypes;
3412
- try {
3413
- const sourceFile = new Project({
3414
- useInMemoryFileSystem: true,
3415
- skipAddingFilesFromTsConfig: true
3416
- }).createSourceFile(filePath, content);
3417
- const classes = sourceFile.getClasses();
3418
- for (const classDeclaration of classes) {
3419
- const pageTypeDecorator = classDeclaration.getDecorator("PageType");
3420
- if (!pageTypeDecorator) continue;
3421
- const className = classDeclaration.getName();
3422
- if (!className) continue;
3423
- const pageTypeConfig = parseDecoratorArgs(pageTypeDecorator);
3424
- const attributes = extractAttributesFromSource(sourceFile, className);
3425
- const regionDefinitions = extractRegionDefinitionsFromSource(sourceFile, className);
3426
- const route = filePathToRoute(filePath, projectRoot);
3427
- const pageTypeMetadata = {
3428
- typeId: pageTypeConfig.id || className.toLowerCase(),
3429
- name: pageTypeConfig.name || toHumanReadableName(className),
3430
- description: pageTypeConfig.description || `Custom page type: ${className}`,
3431
- regionDefinitions,
3432
- supportedAspectTypes: pageTypeConfig.supportedAspectTypes || [],
3433
- attributes,
3434
- route
3435
- };
3436
- pageTypes.push(pageTypeMetadata);
3437
- }
3438
- } catch (error) {
3439
- logger.warn(`Could not process file ${filePath}:`, error.message);
3440
- }
3441
- return pageTypes;
3442
- } catch (error) {
3443
- logger.warn(`Could not read file ${filePath}:`, error.message);
3444
- return [];
3445
- }
3446
- }
3447
- async function processAspectFile(filePath, _projectRoot) {
3448
- try {
3449
- const content = await readFile(filePath, "utf-8");
3450
- const aspects = [];
3451
- if (!filePath.endsWith(".json") || !content.trim().startsWith("{")) return aspects;
3452
- if (!filePath.includes("/aspects/") && !filePath.includes("\\aspects\\")) return aspects;
3453
- try {
3454
- const aspectData = JSON.parse(content);
3455
- const fileName = basename(filePath, ".json");
3456
- if (!aspectData.name || !aspectData.attribute_definitions) return aspects;
3457
- const aspectMetadata = {
3458
- id: fileName,
3459
- name: aspectData.name,
3460
- description: aspectData.description || `Aspect type: ${aspectData.name}`,
3461
- attributeDefinitions: aspectData.attribute_definitions || [],
3462
- supportedObjectTypes: aspectData.supported_object_types || []
3463
- };
3464
- aspects.push(aspectMetadata);
3465
- } catch (parseError) {
3466
- logger.warn(`Could not parse JSON in file ${filePath}:`, parseError.message);
3467
- }
3468
- return aspects;
3469
- } catch (error) {
3470
- logger.warn(`Could not read file ${filePath}:`, error.message);
3471
- return [];
3472
- }
3473
- }
3474
- async function generateComponentCartridge(component, outputDir, dryRun = false) {
3475
- const fileName = toCamelCaseFileName(component.typeId);
3476
- const groupDir = join(outputDir, component.group);
3477
- const outputPath = join(groupDir, `${fileName}.json`);
3478
- if (!dryRun) {
3479
- try {
3480
- await mkdir(groupDir, { recursive: true });
3481
- } catch {}
3482
- const attributeDefinitionGroups = [{
3483
- id: component.typeId,
3484
- name: component.name,
3485
- description: component.description,
3486
- attribute_definitions: component.attributes
3487
- }];
3488
- const cartridgeData = {
3489
- name: component.name,
3490
- description: component.description,
3491
- group: component.group,
3492
- arch_type: ARCH_TYPE_HEADLESS,
3493
- region_definitions: component.regionDefinitions || [],
3494
- attribute_definition_groups: attributeDefinitionGroups
3495
- };
3496
- await writeFile(outputPath, JSON.stringify(cartridgeData, null, 2));
3497
- }
3498
- const prefix = dryRun ? " - [DRY RUN]" : " -";
3499
- logger.debug(`${prefix} ${String(component.typeId)}: ${String(component.name)} (${String(component.attributes.length)} attributes) → ${fileName}.json`);
3500
- }
3501
- async function generatePageTypeCartridge(pageType, outputDir, dryRun = false) {
3502
- const fileName = toCamelCaseFileName(pageType.name);
3503
- const outputPath = join(outputDir, `${fileName}.json`);
3504
- if (!dryRun) {
3505
- const cartridgeData = {
3506
- name: pageType.name,
3507
- description: pageType.description,
3508
- arch_type: ARCH_TYPE_HEADLESS,
3509
- region_definitions: pageType.regionDefinitions || []
3510
- };
3511
- if (pageType.attributes && pageType.attributes.length > 0) cartridgeData.attribute_definition_groups = [{
3512
- id: pageType.typeId || fileName,
3513
- name: pageType.name,
3514
- description: pageType.description,
3515
- attribute_definitions: pageType.attributes
3516
- }];
3517
- if (pageType.supportedAspectTypes) cartridgeData.supported_aspect_types = pageType.supportedAspectTypes;
3518
- if (pageType.route) cartridgeData.route = pageType.route;
3519
- await writeFile(outputPath, JSON.stringify(cartridgeData, null, 2));
3520
- }
3521
- const prefix = dryRun ? " - [DRY RUN]" : " -";
3522
- logger.debug(`${prefix} ${String(pageType.name)}: ${String(pageType.description)} (${String(pageType.attributes.length)} attributes) → ${fileName}.json`);
3523
- }
3524
- async function generateAspectCartridge(aspect, outputDir, dryRun = false) {
3525
- const fileName = toCamelCaseFileName(aspect.id);
3526
- const outputPath = join(outputDir, `${fileName}.json`);
3527
- if (!dryRun) {
3528
- const cartridgeData = {
3529
- name: aspect.name,
3530
- description: aspect.description,
3531
- arch_type: ARCH_TYPE_HEADLESS,
3532
- attribute_definitions: aspect.attributeDefinitions || []
3533
- };
3534
- if (aspect.supportedObjectTypes) cartridgeData.supported_object_types = aspect.supportedObjectTypes;
3535
- await writeFile(outputPath, JSON.stringify(cartridgeData, null, 2));
3536
- }
3537
- const prefix = dryRun ? " - [DRY RUN]" : " -";
3538
- logger.debug(`${prefix} ${String(aspect.name)}: ${String(aspect.description)} (${String(aspect.attributeDefinitions.length)} attributes) → ${fileName}.json`);
3539
- }
3540
- /**
3541
- * Runs ESLint with --fix on the specified directory to format JSON files.
3542
- * This ensures generated JSON files match the project's Prettier/ESLint configuration.
3543
- */
3544
- function lintGeneratedFiles(metadataDir, projectRoot) {
3545
- try {
3546
- logger.debug("🔧 Running ESLint --fix on generated JSON files...");
3547
- execSync(`npx eslint "${metadataDir}/**/*.json" --fix --no-error-on-unmatched-pattern`, {
3548
- cwd: projectRoot,
3549
- stdio: "pipe",
3550
- encoding: "utf-8"
3551
- });
3552
- logger.debug("✅ JSON files formatted successfully");
3553
- } catch (error) {
3554
- const execError = error;
3555
- if (execError.status === 2) {
3556
- const errMsg = execError.stderr || execError.stdout || "Unknown error";
3557
- logger.warn(`⚠️ Could not run ESLint --fix: ${errMsg}`);
3558
- } else if (execError.stderr && execError.stderr.includes("error")) logger.warn(`⚠️ Some linting issues could not be auto-fixed. Run ESLint manually to review.`);
3559
- else logger.debug("✅ JSON files formatted successfully");
3560
- }
3561
- }
3562
- async function generateMetadata(projectDirectory, metadataDirectory, options) {
3563
- try {
3564
- const filePaths = options?.filePaths;
3565
- const isIncrementalMode = filePaths && filePaths.length > 0;
3566
- const dryRun = options?.dryRun || false;
3567
- if (dryRun) logger.debug("🔍 [DRY RUN] Scanning for decorated components and page types...");
3568
- else if (isIncrementalMode) logger.debug(`🔍 Generating metadata for ${filePaths.length} specified file(s)...`);
3569
- else logger.debug("🔍 Generating metadata for decorated components and page types...");
3570
- const projectRoot = resolve(projectDirectory);
3571
- const srcDir = join(projectRoot, "src");
3572
- const metadataDir = resolve(metadataDirectory);
3573
- const componentsOutputDir = join(metadataDir, "components");
3574
- const pagesOutputDir = join(metadataDir, "pages");
3575
- const aspectsOutputDir = join(metadataDir, "aspects");
3576
- if (!dryRun) {
3577
- if (!isIncrementalMode) {
3578
- logger.debug("🗑️ Cleaning existing output directories...");
3579
- for (const outputDir of [
3580
- componentsOutputDir,
3581
- pagesOutputDir,
3582
- aspectsOutputDir
3583
- ]) try {
3584
- await rm(outputDir, {
3585
- recursive: true,
3586
- force: true
3587
- });
3588
- logger.debug(` - Deleted: ${outputDir}`);
3589
- } catch {
3590
- logger.debug(` - Directory not found (skipping): ${outputDir}`);
3591
- }
3592
- } else logger.debug("📝 Incremental mode: existing cartridge files will be preserved/overwritten");
3593
- logger.debug("Creating output directories...");
3594
- for (const outputDir of [
3595
- componentsOutputDir,
3596
- pagesOutputDir,
3597
- aspectsOutputDir
3598
- ]) try {
3599
- await mkdir(outputDir, { recursive: true });
3600
- } catch (error) {
3601
- try {
3602
- await access(outputDir);
3603
- } catch {
3604
- const err = error;
3605
- logger.error(`❌ Failed to create output directory ${outputDir}: ${err.message}`);
3606
- process.exit(1);
3607
- throw err;
3608
- }
3609
- }
3610
- } else if (isIncrementalMode) logger.debug(`📝 [DRY RUN] Would process ${filePaths.length} specific file(s)`);
3611
- else logger.debug("📝 [DRY RUN] Would clean and regenerate all metadata files");
3612
- let files = [];
3613
- if (isIncrementalMode && filePaths) {
3614
- files = filePaths.map((fp) => resolve(projectRoot, fp));
3615
- logger.debug(`📂 Processing ${files.length} specified file(s)...`);
3616
- } else {
3617
- const scanDirectory = async (dir) => {
3618
- const entries = await readdir(dir, { withFileTypes: true });
3619
- for (const entry of entries) {
3620
- const fullPath = join(dir, entry.name);
3621
- if (entry.isDirectory()) {
3622
- if (!SKIP_DIRECTORIES.includes(entry.name)) await scanDirectory(fullPath);
3623
- } else if (entry.isFile() && (extname(entry.name) === ".ts" || extname(entry.name) === ".tsx" || extname(entry.name) === ".json")) files.push(fullPath);
3624
- }
3625
- };
3626
- await scanDirectory(srcDir);
3627
- const configMetadataDir = join(projectRoot, "config-metadata");
3628
- try {
3629
- await access(configMetadataDir);
3630
- await scanDirectory(configMetadataDir);
3631
- } catch (error) {
3632
- if (error.code === "ENOENT") logger.debug(` - Directory not found (skipping): ${configMetadataDir}`);
3633
- else logger.warn(` - Unable to access ${configMetadataDir}:`, error.message);
3634
- }
3635
- }
3636
- const allComponents = [];
3637
- const allPageTypes = [];
3638
- const allAspects = [];
3639
- for (const file of files) {
3640
- const components = await processComponentFile(file, projectRoot);
3641
- allComponents.push(...components);
3642
- const pageTypes = await processPageTypeFile(file, projectRoot);
3643
- allPageTypes.push(...pageTypes);
3644
- const aspects = await processAspectFile(file, projectRoot);
3645
- allAspects.push(...aspects);
3646
- }
3647
- if (allComponents.length === 0 && allPageTypes.length === 0 && allAspects.length === 0) {
3648
- logger.info("⚠️ No decorated components, page types, or aspect files found.");
3649
- return {
3650
- componentsGenerated: 0,
3651
- pageTypesGenerated: 0,
3652
- aspectsGenerated: 0,
3653
- totalFiles: 0
3654
- };
3655
- }
3656
- if (allComponents.length > 0) {
3657
- logger.debug(`✅ Found ${allComponents.length} decorated component(s)`);
3658
- for (const component of allComponents) await generateComponentCartridge(component, componentsOutputDir, dryRun);
3659
- if (dryRun) logger.info(`[DRY RUN] Would generate ${allComponents.length} component metadata file(s)`);
3660
- else logger.info(`Generated ${allComponents.length} component metadata file(s)`);
3661
- }
3662
- if (allPageTypes.length > 0) {
3663
- logger.debug(`✅ Found ${allPageTypes.length} decorated page type(s)`);
3664
- for (const pageType of allPageTypes) await generatePageTypeCartridge(pageType, pagesOutputDir, dryRun);
3665
- if (dryRun) logger.info(`[DRY RUN] Would generate ${allPageTypes.length} page type metadata file(s)`);
3666
- else logger.info(`Generated ${allPageTypes.length} page type metadata file(s)`);
3667
- }
3668
- if (allAspects.length > 0) {
3669
- logger.debug(`✅ Found ${allAspects.length} decorated aspect(s)`);
3670
- for (const aspect of allAspects) await generateAspectCartridge(aspect, aspectsOutputDir, dryRun);
3671
- if (dryRun) logger.info(`[DRY RUN] Would generate ${allAspects.length} aspect metadata file(s)`);
3672
- else logger.info(`Generated ${allAspects.length} aspect metadata file(s)`);
3673
- }
3674
- const shouldLintFix = options?.lintFix !== false;
3675
- if (!dryRun && shouldLintFix && (allComponents.length > 0 || allPageTypes.length > 0 || allAspects.length > 0)) lintGeneratedFiles(metadataDir, projectRoot);
3676
- return {
3677
- componentsGenerated: allComponents.length,
3678
- pageTypesGenerated: allPageTypes.length,
3679
- aspectsGenerated: allAspects.length,
3680
- totalFiles: allComponents.length + allPageTypes.length + allAspects.length
3681
- };
3682
- } catch (error) {
3683
- const err = error;
3684
- logger.error("❌ Error:", err.message);
3685
- process.exit(1);
3686
- throw err;
3687
- }
3688
- }
3689
2396
 
3690
2397
  //#endregion
3691
- export { clearCache, createServer, storefrontNextTargets as default, extractPatterns, generateMetadata, hybridProxyPlugin, loadConfigFromEnv, loadProjectConfig, shouldRouteToNext, testPatterns, transformTargetPlaceholderPlugin, trimExtensions, uiTargetDevModePlugin };
2398
+ export { storefrontNextTargets as default, hybridProxyPlugin, shouldRouteToNext, transformTargetPlaceholderPlugin, uiTargetDevModePlugin };
3692
2399
  //# sourceMappingURL=index.js.map