@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/README.md +1 -1
- package/dist/cartridge-services/index.d.ts.map +1 -1
- package/dist/cartridge-services/index.js +1 -0
- package/dist/cartridge-services/index.js.map +1 -1
- package/dist/commands/extensions/install.js +1 -1
- package/dist/commands/extensions/remove.js +2 -2
- package/dist/commands/scapi/add.js +30 -9
- package/dist/commands/scapi/available.js +225 -0
- package/dist/commands/scapi/list.js +24 -11
- package/dist/commands/scapi/remove.js +8 -2
- package/dist/configs/react-router.config.d.ts +7 -0
- package/dist/configs/react-router.config.d.ts.map +1 -1
- package/dist/configs/react-router.config.js +12 -1
- package/dist/configs/react-router.config.js.map +1 -1
- package/dist/generate-cartridge.js +1 -0
- package/dist/generate-custom-clients.js +127 -7
- package/dist/hooks/init.js +1 -0
- package/dist/index.d.ts +5 -224
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +281 -1574
- package/dist/index.js.map +1 -1
- package/dist/logger.js +1 -1
- package/dist/mrt/ssr.mjs +1 -1
- package/dist/mrt/ssr.mjs.map +1 -1
- package/dist/mrt/streamingHandler.mjs +2 -2
- package/dist/mrt/streamingHandler.mjs.map +1 -1
- package/dist/react-router/Scripts.d.ts +4 -16
- package/dist/react-router/Scripts.d.ts.map +1 -1
- package/dist/react-router/Scripts.js +4 -16
- package/dist/react-router/Scripts.js.map +1 -1
- package/dist/schema-utils.js +16 -4
- package/package.json +6 -5
package/dist/index.js
CHANGED
|
@@ -1,40 +1,19 @@
|
|
|
1
|
-
import 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
|
|
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
|
|
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$
|
|
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"))
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1281
|
+
function findUserEntry(appDirectory, basename) {
|
|
1217
1282
|
for (const ext of ENTRY_EXTENSIONS) {
|
|
1218
|
-
const filePath = path.resolve(appDirectory, basename
|
|
1219
|
-
if (fs$
|
|
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
|
|
1320
|
-
if (path.dirname(relative$1) !== "." || basename
|
|
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
|
-
|
|
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/${
|
|
2004
|
-
else proxyReq.path = `/s/${
|
|
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 (
|
|
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")
|
|
2023
|
-
const
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
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 {
|
|
2398
|
+
export { storefrontNextTargets as default, hybridProxyPlugin, shouldRouteToNext, transformTargetPlaceholderPlugin, uiTargetDevModePlugin };
|
|
3692
2399
|
//# sourceMappingURL=index.js.map
|