diff --git a/.changeset/five-trainers-admire.md b/.changeset/five-trainers-admire.md
new file mode 100644
index 0000000000..e624fc222a
--- /dev/null
+++ b/.changeset/five-trainers-admire.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli': patch
+---
+
+Fix webpack dev server issue where it wasn't serving `index.html` from correct endpoint on subsequent requests.
diff --git a/.changeset/young-chairs-check.md b/.changeset/young-chairs-check.md
new file mode 100644
index 0000000000..e70073e9df
--- /dev/null
+++ b/.changeset/young-chairs-check.md
@@ -0,0 +1,5 @@
+---
+'@backstage/core-app-api': patch
+---
+
+Apps will now rewrite the `app.baseUrl` configuration to match the current `location.origin`. The `backend.baseUrl` will also be rewritten in the same way when the `app.baseUrl` and `backend.baseUrl` have matching origins. This will reduce the need for separate frontend builds for different environments.
diff --git a/packages/cli/src/lib/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts
index fb11316fa6..cef55d3265 100644
--- a/packages/cli/src/lib/bundler/server.ts
+++ b/packages/cli/src/lib/bundler/server.ts
@@ -60,6 +60,9 @@ export async function serveBundle(options: ServeOptions) {
// Paths with dots should still use the history fallback.
// See https://github.com/facebookincubator/create-react-app/issues/387.
disableDotRule: true,
+
+ // The index needs to be rewritten relative to the new public path, including subroutes.
+ index: `${config.output?.publicPath}index.html`,
},
https:
url.protocol === 'https:'
diff --git a/packages/core-app-api/src/app/AppManager.compat.test.tsx b/packages/core-app-api/src/app/AppManager.compat.test.tsx
index bdebd79dc4..cfbcc87a01 100644
--- a/packages/core-app-api/src/app/AppManager.compat.test.tsx
+++ b/packages/core-app-api/src/app/AppManager.compat.test.tsx
@@ -81,7 +81,9 @@ describe.each(['beta', 'stable'])('react-router %s', rrVersion => {
configLoader: async () => [
{
context: 'test',
- data: { app: { baseUrl: 'http://localhost/foo' } },
+ data: {
+ app: { baseUrl: 'http://localhost/foo' },
+ },
},
],
bindRoutes: () => {},
diff --git a/packages/core-app-api/src/app/AppManager.stable.test.tsx b/packages/core-app-api/src/app/AppManager.stable.test.tsx
index a9948a6070..ba16353d47 100644
--- a/packages/core-app-api/src/app/AppManager.stable.test.tsx
+++ b/packages/core-app-api/src/app/AppManager.stable.test.tsx
@@ -62,7 +62,9 @@ describe('AppManager', () => {
configLoader: async () => [
{
context: 'test',
- data: { app: { baseUrl: 'http://localhost/foo' } },
+ data: {
+ app: { baseUrl: 'http://localhost/foo' },
+ },
},
],
});
@@ -94,7 +96,9 @@ describe('AppManager', () => {
configLoader: async () => [
{
context: 'test',
- data: { app: { baseUrl: 'http://localhost/foo' } },
+ data: {
+ app: { baseUrl: 'http://localhost/foo' },
+ },
},
],
});
diff --git a/packages/core-app-api/src/app/AppManager.test.tsx b/packages/core-app-api/src/app/AppManager.test.tsx
index f81f382650..03bd34eba1 100644
--- a/packages/core-app-api/src/app/AppManager.test.tsx
+++ b/packages/core-app-api/src/app/AppManager.test.tsx
@@ -620,4 +620,110 @@ describe('Integration Test', () => {
),
]);
});
+
+ describe('relative url resolvers', () => {
+ it.each([
+ [
+ [document.location.href, document.location.href],
+ {
+ backend: {
+ baseUrl: 'http://localhost:8008/',
+ },
+ app: {
+ baseUrl: 'http://localhost:8008/',
+ },
+ },
+ ],
+ [
+ [
+ `${document.location.origin}/backstage`,
+ `${document.location.origin}/backstage`,
+ ],
+ {
+ backend: {
+ baseUrl: 'http://test.com/backstage',
+ },
+ app: {
+ baseUrl: 'http://test.com/backstage',
+ },
+ },
+ ],
+ [
+ [
+ `${document.location.origin}/backstage/instance`,
+ `${document.location.origin}/backstage/instance`,
+ ],
+ {
+ backend: {
+ baseUrl: 'http://test.com/backstage/instance',
+ },
+ app: {
+ baseUrl: 'http://test.com/backstage/instance',
+ },
+ },
+ ],
+ [
+ [
+ `${document.location.origin}/backstage/instance`,
+ `http://test.com/backstage/instance`,
+ ],
+ {
+ backend: {
+ baseUrl: 'http://test.com/backstage/instance',
+ },
+ app: {
+ baseUrl: 'http://test-front.com/backstage/instance',
+ },
+ },
+ ],
+ ])(
+ 'should be %p when %p',
+ async ([expectedAppUrl, expectedBackendUrl], data) => {
+ const app = new AppManager({
+ apis: [],
+ defaultApis: [],
+ themes: [
+ {
+ id: 'light',
+ title: 'Light Theme',
+ variant: 'light',
+ Provider: ({ children }) => <>{children}>,
+ },
+ ],
+ icons,
+ plugins: [],
+ components,
+ configLoader: async () => [
+ {
+ context: 'test',
+ data,
+ },
+ ],
+ });
+
+ const Provider = app.getProvider();
+ const ConfigDisplay = ({ configString }: { configString: string }) => {
+ const config = useApi(configApiRef);
+ return (
+
+ {configString}: {config?.getString(configString)}
+
+ );
+ };
+
+ const dom = await renderWithEffects(
+
+
+
+ ,
+ );
+
+ expect(dom.getByText(`app.baseUrl: ${expectedAppUrl}`)).toBeTruthy();
+
+ expect(
+ dom.getByText(`backend.baseUrl: ${expectedBackendUrl}`),
+ ).toBeTruthy();
+ },
+ );
+ });
});
diff --git a/packages/core-app-api/src/app/AppManager.tsx b/packages/core-app-api/src/app/AppManager.tsx
index b7eafb0100..155ff6ad3b 100644
--- a/packages/core-app-api/src/app/AppManager.tsx
+++ b/packages/core-app-api/src/app/AppManager.tsx
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-import { Config } from '@backstage/config';
+import { AppConfig, Config } from '@backstage/config';
import React, {
ComponentType,
createContext,
@@ -154,7 +154,71 @@ function useConfigLoader(
};
}
- const configReader = ConfigReader.fromConfigs(config.value ?? []);
+ let configReader;
+ /**
+ * config.value can be undefined or empty. If it's either, don't bother overriding anything.
+ */
+ if (config.value?.length) {
+ const urlConfigReader = ConfigReader.fromConfigs(config.value);
+
+ /**
+ * Return the origin of the given URL.
+ * @param url An absolute URL.
+ * @returns The given URL's origin.
+ * @throws If fullUrl is not a correctly formatted absolute URL.
+ */
+ const getOrigin = (url: string) => new URL(url).origin;
+
+ /**
+ * Resolve an absolute URL as relative to the current document.
+ * @param fullUrl URL to resolve.
+ * @returns Absolute URL with origin as the current document origin.
+ * @throws If fullUrl is not a correctly formatted absolute URL.
+ */
+ const overrideOrigin = (fullUrl: string) => {
+ return new URL(
+ fullUrl.replace(getOrigin(fullUrl), ''),
+ document.location.origin,
+ ).href;
+ };
+
+ /**
+ * Test configs may not define `app.baseUrl` or `backend.baseUrl` and we
+ * don't want to enforce here.
+ */
+ const appBaseUrl = urlConfigReader.getOptionalString('app.baseUrl');
+ const backendBaseUrl = urlConfigReader.getOptionalString('backend.baseUrl');
+
+ let configs = config.value;
+ const relativeResolverConfig: AppConfig = {
+ data: {},
+ context: 'relative-resolver',
+ };
+ if (appBaseUrl && backendBaseUrl) {
+ const appOrigin = getOrigin(appBaseUrl);
+ const backendOrigin = getOrigin(backendBaseUrl);
+
+ if (appOrigin === backendOrigin) {
+ relativeResolverConfig.data.backend = {
+ baseUrl: overrideOrigin(backendBaseUrl),
+ };
+ }
+ }
+ if (appBaseUrl) {
+ relativeResolverConfig.data.app = {
+ baseUrl: overrideOrigin(appBaseUrl),
+ };
+ }
+ /**
+ * Only add the relative config if there is actually data to add.
+ */
+ if (Object.keys(relativeResolverConfig.data).length) {
+ configs = configs.concat([relativeResolverConfig]);
+ }
+ configReader = ConfigReader.fromConfigs(configs);
+ } else {
+ configReader = ConfigReader.fromConfigs([]);
+ }
return { api: configReader };
}
diff --git a/packages/dev-utils/src/devApp/render.test.tsx b/packages/dev-utils/src/devApp/render.test.tsx
index 83a24bd9b2..2da6ef370a 100644
--- a/packages/dev-utils/src/devApp/render.test.tsx
+++ b/packages/dev-utils/src/devApp/render.test.tsx
@@ -24,7 +24,12 @@ const anyEnv = (process.env = { ...process.env }) as any;
describe('DevAppBuilder', () => {
it('should be able to render a component in a dev app', async () => {
anyEnv.APP_CONFIG = [
- { context: 'test', data: { app: { title: 'Test App' } } },
+ {
+ context: 'test',
+ data: {
+ app: { title: 'Test App' },
+ },
+ },
];
const MyComponent = () => {