1313 * See the License for the specific language governing permissions and
1414 * limitations under the License.
1515 */
16- import { join } from 'node:path' ;
16+ import { join , sep } from 'node:path' ;
1717import { Messages } from '@salesforce/core/messages' ;
1818import { SfError } from '@salesforce/core/sfError' ;
1919import { SourcePath } from '../../common/types' ;
@@ -24,9 +24,23 @@ import { BundleSourceAdapter } from './bundleSourceAdapter';
2424Messages . importMessagesDirectory ( __dirname ) ;
2525const messages = Messages . loadMessages ( '@salesforce/source-deploy-retrieve' , 'sdr' ) ;
2626
27+ type WebApplicationConfig = {
28+ outputDir : string ;
29+ routing : {
30+ trailingSlash : string ;
31+ fallback : string ;
32+ rewrites ?: Array < { route : string ; rewrite : string } > ;
33+ } ;
34+ } ;
35+
36+ /**
37+ * Source adapter for WebApplication bundles.
38+ *
39+ * If `webapplication.json` is present (and not force-ignored) we validate its
40+ * required fields and check that the files it references exist on disk.
41+ * Otherwise we require a non-empty `dist/index.html`.
42+ */
2743export class WebApplicationsSourceAdapter extends BundleSourceAdapter {
28- // Enforces WebApplication bundle requirements for source/deploy while staying
29- // compatible with metadata-only retrievals.
3044 protected populate (
3145 trigger : SourcePath ,
3246 component ?: SourceComponent ,
@@ -41,14 +55,11 @@ export class WebApplicationsSourceAdapter extends BundleSourceAdapter {
4155 const appName = baseName ( contentPath ) ;
4256 const expectedXmlPath = join ( contentPath , `${ appName } .webapplication-meta.xml` ) ;
4357 if ( ! this . tree . exists ( expectedXmlPath ) ) {
44- throw new SfError (
45- messages . getMessage ( 'error_expected_source_files' , [ expectedXmlPath , this . type . name ] ) ,
46- 'ExpectedSourceFilesError'
47- ) ;
58+ this . expectedSourceError ( expectedXmlPath ) ;
4859 }
4960
5061 const resolvedSource =
51- source . xml && source . xml === expectedXmlPath
62+ source . xml === expectedXmlPath
5263 ? source
5364 : new SourceComponent (
5465 {
@@ -65,28 +76,108 @@ export class WebApplicationsSourceAdapter extends BundleSourceAdapter {
6576
6677 if ( isResolvingSource ) {
6778 const descriptorPath = join ( contentPath , 'webapplication.json' ) ;
68- const xmlFileName = `${ appName } .webapplication-meta.xml` ;
69- const contentEntries = ( this . tree . readDirectory ( contentPath ) ?? [ ] ) . filter (
70- ( entry ) => entry !== xmlFileName && entry !== 'webapplication.json'
71- ) ;
72- if ( contentEntries . length === 0 ) {
73- // For deploy/source, we expect at least one non-metadata content file (e.g. index.html).
74- throw new SfError (
75- messages . getMessage ( 'error_expected_source_files' , [ contentPath , this . type . name ] ) ,
76- 'ExpectedSourceFilesError'
77- ) ;
78- }
79- if ( ! this . tree . exists ( descriptorPath ) ) {
80- throw new SfError (
81- messages . getMessage ( 'error_expected_source_files' , [ descriptorPath , this . type . name ] ) ,
82- 'ExpectedSourceFilesError'
83- ) ;
84- }
85- if ( this . forceIgnore . denies ( descriptorPath ) ) {
86- throw messages . createError ( 'noSourceIgnore' , [ this . type . name , descriptorPath ] ) ;
79+ const hasDescriptor = this . tree . exists ( descriptorPath ) && ! this . forceIgnore . denies ( descriptorPath ) ;
80+
81+ if ( hasDescriptor ) {
82+ this . validateDescriptor ( descriptorPath , contentPath ) ;
83+ } else {
84+ this . validateDistFolder ( contentPath ) ;
8785 }
8886 }
8987
9088 return resolvedSource ;
9189 }
90+
91+ private validateDistFolder ( contentPath : SourcePath ) : void {
92+ const distPath = join ( contentPath , 'dist' ) ;
93+ const indexPath = join ( distPath , 'index.html' ) ;
94+
95+ if ( ! this . tree . exists ( distPath ) || ! this . tree . isDirectory ( distPath ) ) {
96+ throw new SfError (
97+ "When webapplication.json is not present, a 'dist' folder containing 'index.html' is required. The 'dist' folder was not found." ,
98+ 'ExpectedSourceFilesError'
99+ ) ;
100+ }
101+ if ( ! this . tree . exists ( indexPath ) ) {
102+ throw new SfError (
103+ "When webapplication.json is not present, a 'dist/index.html' file is required as the entry point. The file was not found." ,
104+ 'ExpectedSourceFilesError'
105+ ) ;
106+ }
107+ if ( this . tree . readFileSync ( indexPath ) . length === 0 ) {
108+ throw new SfError (
109+ "When webapplication.json is not present, 'dist/index.html' must exist and be non-empty. The file was found but is empty." ,
110+ 'ExpectedSourceFilesError'
111+ ) ;
112+ }
113+ }
114+
115+ private validateDescriptor ( descriptorPath : SourcePath , contentPath : SourcePath ) : void {
116+ const raw = this . tree . readFileSync ( descriptorPath ) ;
117+ let config : WebApplicationConfig ;
118+
119+ try {
120+ config = JSON . parse ( raw . toString ( 'utf8' ) ) as WebApplicationConfig ;
121+ } catch ( e ) {
122+ const detail = e instanceof Error ? e . message : String ( e ) ;
123+ throw new SfError ( `Invalid JSON in webapplication.json: ${ detail } ` , 'InvalidJsonError' ) ;
124+ }
125+
126+ if ( ! config . outputDir || typeof config . outputDir !== 'string' ) {
127+ throw new SfError (
128+ "webapplication.json is missing required field 'outputDir'" ,
129+ 'InvalidWebApplicationConfigError'
130+ ) ;
131+ }
132+ const outputDirPath = join ( contentPath , config . outputDir ) ;
133+ if ( ! this . tree . exists ( outputDirPath ) || ! this . tree . isDirectory ( outputDirPath ) ) {
134+ this . expectedSourceError ( outputDirPath ) ;
135+ }
136+
137+ if ( ! config . routing || typeof config . routing !== 'object' ) {
138+ throw new SfError ( "webapplication.json is missing required field 'routing'" , 'InvalidWebApplicationConfigError' ) ;
139+ }
140+ if ( ! config . routing . trailingSlash || typeof config . routing . trailingSlash !== 'string' ) {
141+ throw new SfError (
142+ "webapplication.json is missing required field 'routing.trailingSlash'" ,
143+ 'InvalidWebApplicationConfigError'
144+ ) ;
145+ }
146+ if ( ! config . routing . fallback || typeof config . routing . fallback !== 'string' ) {
147+ throw new SfError (
148+ "webapplication.json is missing required field 'routing.fallback'" ,
149+ 'InvalidWebApplicationConfigError'
150+ ) ;
151+ }
152+
153+ // Strip leading path separator (path.sep and / for URL-style paths)
154+ const sepChar = sep . replace ( / \\ / g, '\\\\' ) ;
155+ const stripLeadingSep = ( p : string ) => p . replace ( new RegExp ( `^[${ sepChar } /]` ) , '' ) ;
156+ const fallbackPath = join ( outputDirPath , stripLeadingSep ( config . routing . fallback ) ) ;
157+ if ( ! this . tree . exists ( fallbackPath ) ) {
158+ throw new SfError (
159+ "The filepath defined in the webapplication.json -> routing.fallback was not found. Ensure this file exists at the location defined." ,
160+ 'ExpectedSourceFilesError'
161+ ) ;
162+ }
163+
164+ // rewrites are optional, but every target must resolve
165+ if ( Array . isArray ( config . routing . rewrites ) ) {
166+ for ( const { rewrite } of config . routing . rewrites ) {
167+ if ( rewrite ) {
168+ const rewritePath = join ( outputDirPath , stripLeadingSep ( rewrite ) ) ;
169+ if ( ! this . tree . exists ( rewritePath ) ) {
170+ this . expectedSourceError ( rewritePath ) ;
171+ }
172+ }
173+ }
174+ }
175+ }
176+
177+ private expectedSourceError ( path : SourcePath ) : never {
178+ throw new SfError (
179+ messages . getMessage ( 'error_expected_source_files' , [ path , this . type . name ] ) ,
180+ 'ExpectedSourceFilesError'
181+ ) ;
182+ }
92183}
0 commit comments