Skip to content

Commit 6d0f99a

Browse files
clydinfilipesilva
authored andcommitted
feat(@angular-devkit/build-angular): support JSON comments in dev-server proxy configuration file
The `proxyConfig` option for the `dev-server` builder now supports JSON files containing comments and trailing commas. Several additional tests regarding the use of ESM and CommonJS JavaScript configuration files were also added to reduce the likelihood of future regressions. Closes: #21862
1 parent 7d642fe commit 6d0f99a

File tree

4 files changed

+176
-15
lines changed

4 files changed

+176
-15
lines changed

packages/angular_devkit/build_angular/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ ts_library(
147147
"@npm//glob",
148148
"@npm//https-proxy-agent",
149149
"@npm//inquirer",
150+
"@npm//jsonc-parser",
150151
"@npm//karma",
151152
"@npm//karma-source-map-support",
152153
"@npm//less",

packages/angular_devkit/build_angular/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"glob": "7.2.0",
3636
"https-proxy-agent": "5.0.0",
3737
"inquirer": "8.2.0",
38+
"jsonc-parser": "3.0.0",
3839
"karma-source-map-support": "1.4.0",
3940
"less": "4.1.2",
4041
"less-loader": "10.2.0",

packages/angular_devkit/build_angular/src/builders/dev-server/tests/options/proxy-config_spec.ts

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ describeBuilder(serveWebpackBrowser, DEV_SERVER_BUILDER_INFO, (harness) => {
2525
await harness.writeFile('src/main.ts', '');
2626
});
2727

28-
it('proxies requests based on the JSON proxy configuration file provided in the option', async () => {
28+
it('proxies requests based on the `.json` proxy configuration file provided in the option', async () => {
2929
harness.useTarget('serve', {
3030
...BASE_OPTIONS,
3131
proxyConfig: 'proxy.config.json',
@@ -49,7 +49,34 @@ describeBuilder(serveWebpackBrowser, DEV_SERVER_BUILDER_INFO, (harness) => {
4949
}
5050
});
5151

52-
it('proxies requests based on the JS proxy configuration file provided in the option', async () => {
52+
it('proxies requests based on the `.json` (with comments) proxy configuration file provided in the option', async () => {
53+
harness.useTarget('serve', {
54+
...BASE_OPTIONS,
55+
proxyConfig: 'proxy.config.json',
56+
});
57+
58+
const proxyServer = createProxyServer();
59+
try {
60+
await new Promise<void>((resolve) => proxyServer.listen(0, '127.0.0.1', resolve));
61+
const proxyAddress = proxyServer.address() as import('net').AddressInfo;
62+
63+
await harness.writeFiles({
64+
'proxy.config.json': `
65+
// JSON file with comments
66+
{ "/api/*": { "target": "http://127.0.0.1:${proxyAddress.port}" } }
67+
`,
68+
});
69+
70+
const { result, response } = await executeOnceAndFetch(harness, '/api/test');
71+
72+
expect(result?.success).toBeTrue();
73+
expect(await response?.text()).toContain('TEST_API_RETURN');
74+
} finally {
75+
await new Promise<void>((resolve) => proxyServer.close(() => resolve()));
76+
}
77+
});
78+
79+
it('proxies requests based on the `.js` (CommonJS) proxy configuration file provided in the option', async () => {
5380
harness.useTarget('serve', {
5481
...BASE_OPTIONS,
5582
proxyConfig: 'proxy.config.js',
@@ -73,7 +100,56 @@ describeBuilder(serveWebpackBrowser, DEV_SERVER_BUILDER_INFO, (harness) => {
73100
}
74101
});
75102

76-
it('proxies requests based on the MJS proxy configuration file provided in the option', async () => {
103+
it('proxies requests based on the `.js` (ESM) proxy configuration file provided in the option', async () => {
104+
harness.useTarget('serve', {
105+
...BASE_OPTIONS,
106+
proxyConfig: 'proxy.config.js',
107+
});
108+
109+
const proxyServer = createProxyServer();
110+
try {
111+
await new Promise<void>((resolve) => proxyServer.listen(0, '127.0.0.1', resolve));
112+
const proxyAddress = proxyServer.address() as import('net').AddressInfo;
113+
114+
await harness.writeFiles({
115+
'proxy.config.js': `export default { "/api/*": { "target": "http://127.0.0.1:${proxyAddress.port}" } }`,
116+
'package.json': '{ "type": "module" }',
117+
});
118+
119+
const { result, response } = await executeOnceAndFetch(harness, '/api/test');
120+
121+
expect(result?.success).toBeTrue();
122+
expect(await response?.text()).toContain('TEST_API_RETURN');
123+
} finally {
124+
await new Promise<void>((resolve) => proxyServer.close(() => resolve()));
125+
}
126+
});
127+
128+
it('proxies requests based on the `.cjs` proxy configuration file provided in the option', async () => {
129+
harness.useTarget('serve', {
130+
...BASE_OPTIONS,
131+
proxyConfig: 'proxy.config.cjs',
132+
});
133+
134+
const proxyServer = createProxyServer();
135+
try {
136+
await new Promise<void>((resolve) => proxyServer.listen(0, '127.0.0.1', resolve));
137+
const proxyAddress = proxyServer.address() as import('net').AddressInfo;
138+
139+
await harness.writeFiles({
140+
'proxy.config.cjs': `module.exports = { "/api/*": { "target": "http://127.0.0.1:${proxyAddress.port}" } }`,
141+
});
142+
143+
const { result, response } = await executeOnceAndFetch(harness, '/api/test');
144+
145+
expect(result?.success).toBeTrue();
146+
expect(await response?.text()).toContain('TEST_API_RETURN');
147+
} finally {
148+
await new Promise<void>((resolve) => proxyServer.close(() => resolve()));
149+
}
150+
});
151+
152+
it('proxies requests based on the `.mjs` proxy configuration file provided in the option', async () => {
77153
harness.useTarget('serve', {
78154
...BASE_OPTIONS,
79155
proxyConfig: 'proxy.config.mjs',
@@ -112,6 +188,30 @@ describeBuilder(serveWebpackBrowser, DEV_SERVER_BUILDER_INFO, (harness) => {
112188
}),
113189
);
114190
});
191+
192+
it('throws an error when JSON proxy configuration file cannot be parsed', async () => {
193+
harness.useTarget('serve', {
194+
...BASE_OPTIONS,
195+
proxyConfig: 'proxy.config.json',
196+
});
197+
198+
// Create a JSON file with a parse error (target property has no value)
199+
await harness.writeFiles({
200+
'proxy.config.json': `
201+
// JSON file with comments
202+
{ "/api/*": { "target": } }
203+
`,
204+
});
205+
206+
const { result, error } = await harness.executeOnce({ outputLogsOnException: false });
207+
208+
expect(result).toBeUndefined();
209+
expect(error).toEqual(
210+
jasmine.objectContaining({
211+
message: jasmine.stringMatching('contains parse errors:\\n\\[3, 35\\] ValueExpected'),
212+
}),
213+
);
214+
});
115215
});
116216
});
117217

packages/angular_devkit/build_angular/src/webpack/configs/dev-server.ts

Lines changed: 71 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
*/
88

99
import { logging, tags } from '@angular-devkit/core';
10-
import { existsSync } from 'fs';
11-
import { posix, resolve } from 'path';
10+
import { existsSync, promises as fsPromises } from 'fs';
11+
import { extname, posix, resolve } from 'path';
1212
import * as url from 'url';
1313
import { Configuration, RuleSetRule } from 'webpack';
1414
import { Configuration as DevServerConfiguration } from 'webpack-dev-server';
@@ -164,22 +164,81 @@ async function addProxyConfig(root: string, proxyConfig: string | undefined) {
164164
}
165165

166166
const proxyPath = resolve(root, proxyConfig);
167-
if (existsSync(proxyPath)) {
168-
try {
167+
168+
if (!existsSync(proxyPath)) {
169+
throw new Error(`Proxy configuration file ${proxyPath} does not exist.`);
170+
}
171+
172+
switch (extname(proxyPath)) {
173+
case '.json': {
174+
const content = await fsPromises.readFile(proxyPath, 'utf-8');
175+
176+
const { parse, printParseErrorCode } = await import('jsonc-parser');
177+
const parseErrors: import('jsonc-parser').ParseError[] = [];
178+
const proxyConfiguration = parse(content, parseErrors, { allowTrailingComma: true });
179+
180+
if (parseErrors.length > 0) {
181+
let errorMessage = `Proxy configuration file ${proxyPath} contains parse errors:`;
182+
for (const parseError of parseErrors) {
183+
const { line, column } = getJsonErrorLineColumn(parseError.offset, content);
184+
errorMessage += `\n[${line}, ${column}] ${printParseErrorCode(parseError.error)}`;
185+
}
186+
throw new Error(errorMessage);
187+
}
188+
189+
return proxyConfiguration;
190+
}
191+
case '.mjs':
192+
// Load the ESM configuration file using the TypeScript dynamic import workaround.
193+
// Once TypeScript provides support for keeping the dynamic import this workaround can be
194+
// changed to a direct dynamic import.
195+
return (await loadEsmModule<{ default: unknown }>(url.pathToFileURL(proxyPath))).default;
196+
case '.cjs':
169197
return require(proxyPath);
170-
} catch (e) {
171-
if (e.code === 'ERR_REQUIRE_ESM') {
172-
// Load the ESM configuration file using the TypeScript dynamic import workaround.
173-
// Once TypeScript provides support for keeping the dynamic import this workaround can be
174-
// changed to a direct dynamic import.
175-
return (await loadEsmModule<{ default: unknown }>(url.pathToFileURL(proxyPath))).default;
198+
default:
199+
// The file could be either CommonJS or ESM.
200+
// CommonJS is tried first then ESM if loading fails.
201+
try {
202+
return require(proxyPath);
203+
} catch (e) {
204+
if (e.code === 'ERR_REQUIRE_ESM') {
205+
// Load the ESM configuration file using the TypeScript dynamic import workaround.
206+
// Once TypeScript provides support for keeping the dynamic import this workaround can be
207+
// changed to a direct dynamic import.
208+
return (await loadEsmModule<{ default: unknown }>(url.pathToFileURL(proxyPath))).default;
209+
}
210+
211+
throw e;
176212
}
213+
}
214+
}
215+
216+
/**
217+
* Calculates the line and column for an error offset in the content of a JSON file.
218+
* @param location The offset error location from the beginning of the content.
219+
* @param content The full content of the file containing the error.
220+
* @returns An object containing the line and column
221+
*/
222+
function getJsonErrorLineColumn(offset: number, content: string) {
223+
if (offset === 0) {
224+
return { line: 1, column: 1 };
225+
}
177226

178-
throw e;
227+
let line = 0;
228+
let position = 0;
229+
// eslint-disable-next-line no-constant-condition
230+
while (true) {
231+
++line;
232+
233+
const nextNewline = content.indexOf('\n', position);
234+
if (nextNewline === -1 || nextNewline > offset) {
235+
break;
179236
}
237+
238+
position = nextNewline + 1;
180239
}
181240

182-
throw new Error('Proxy config file ' + proxyPath + ' does not exist.');
241+
return { line, column: offset - position + 1 };
183242
}
184243

185244
/**

0 commit comments

Comments
 (0)