Skip to content

Commit 68a8f79

Browse files
lesya7cursoragent
andcommitted
feat: make webapplication.json optional with validation
- webapplication.json optional: when absent or force-ignored, require non-empty dist/index.html - Validate required fields (outputDir, routing, trailingSlash, fallback) with clear error messages - Validate fallback and rewrite targets exist on disk - Precise error messages for dist fallback (missing folder, missing file, empty file) - Use path.sep for cross-platform path handling Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d52ebd0 commit 68a8f79

File tree

6 files changed

+499
-99
lines changed

6 files changed

+499
-99
lines changed

src/resolve/adapters/webApplicationsSourceAdapter.ts

Lines changed: 118 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
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';
1717
import { Messages } from '@salesforce/core/messages';
1818
import { SfError } from '@salesforce/core/sfError';
1919
import { SourcePath } from '../../common/types';
@@ -24,9 +24,23 @@ import { BundleSourceAdapter } from './bundleSourceAdapter';
2424
Messages.importMessagesDirectory(__dirname);
2525
const 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+
*/
2743
export 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

Comments
 (0)