diff --git a/.changeset/chatty-cobras-cheer.md b/.changeset/chatty-cobras-cheer.md
new file mode 100644
index 0000000000..c8203e58c0
--- /dev/null
+++ b/.changeset/chatty-cobras-cheer.md
@@ -0,0 +1,5 @@
+---
+'@backstage/config-loader': patch
+---
+
+Correctly resolve config targets into absolute paths
diff --git a/.changeset/chilled-knives-rule.md b/.changeset/chilled-knives-rule.md
new file mode 100644
index 0000000000..f246beabbd
--- /dev/null
+++ b/.changeset/chilled-knives-rule.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-catalog-import': patch
+---
+
+Create an experimental plugin that is compatible with the declarative integration system, it is exported from the `/alpha` subpath.
diff --git a/.changeset/chilly-terms-behave.md b/.changeset/chilly-terms-behave.md
new file mode 100644
index 0000000000..8ee1ad8375
--- /dev/null
+++ b/.changeset/chilly-terms-behave.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-search-backend-module-stack-overflow-collator': minor
+---
+
+Extract a package for the Stack Overflow new backend system plugin.
diff --git a/.changeset/dirty-ducks-behave.md b/.changeset/dirty-ducks-behave.md
new file mode 100644
index 0000000000..c7bea8ea0a
--- /dev/null
+++ b/.changeset/dirty-ducks-behave.md
@@ -0,0 +1,5 @@
+---
+'@backstage/core-components': patch
+---
+
+Fix `RoutedTabs` so that it does not explode without tabs.
diff --git a/.changeset/dry-days-invite.md b/.changeset/dry-days-invite.md
new file mode 100644
index 0000000000..dcf052acd2
--- /dev/null
+++ b/.changeset/dry-days-invite.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli': patch
+---
+
+Switch from using deprecated `@esbuild-kit/*` packages to using `tsx`. This also switches to using the new module loader `register` API when available, avoiding the experimental warning when starting backends.
diff --git a/.changeset/fast-bears-lick.md b/.changeset/fast-bears-lick.md
new file mode 100644
index 0000000000..8e0dc8e60b
--- /dev/null
+++ b/.changeset/fast-bears-lick.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-catalog': patch
+---
+
+Migrate catalog entity cards to new frontend system extension format.
diff --git a/.changeset/flat-ducks-buy.md b/.changeset/flat-ducks-buy.md
new file mode 100644
index 0000000000..5c952faaf1
--- /dev/null
+++ b/.changeset/flat-ducks-buy.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-search-backend-node': patch
+---
+
+Fix highlighting for non-string fields on the `Lunr` search engine implementation.
diff --git a/.changeset/fluffy-years-shake.md b/.changeset/fluffy-years-shake.md
new file mode 100644
index 0000000000..9612690d62
--- /dev/null
+++ b/.changeset/fluffy-years-shake.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-catalog': patch
+---
+
+Use default extensions boundary and suspense on the alpha declarative `createCatalogFilterExtension` extension factory.
diff --git a/.changeset/fresh-crews-promise.md b/.changeset/fresh-crews-promise.md
new file mode 100644
index 0000000000..fc9859086a
--- /dev/null
+++ b/.changeset/fresh-crews-promise.md
@@ -0,0 +1,6 @@
+---
+'@backstage/plugin-techdocs-module-addons-contrib': patch
+'@backstage/plugin-techdocs': patch
+---
+
+Fixed navigation bug that caused users to not be scrolled to the top of a new page. Fixed navigation bug where using backwards and forwards browser navigation did not scroll users to the correct place on the TechDoc page.
diff --git a/.changeset/fresh-schools-thank.md b/.changeset/fresh-schools-thank.md
new file mode 100644
index 0000000000..1e32f82051
--- /dev/null
+++ b/.changeset/fresh-schools-thank.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-catalog-backend-module-gitlab': patch
+---
+
+Added try catch around fetching gitlab group users to prevent refresh from failing completely while only a select number of groups might not be able to load correctly.
diff --git a/.changeset/giant-cars-walk.md b/.changeset/giant-cars-walk.md
new file mode 100644
index 0000000000..5e313faa3b
--- /dev/null
+++ b/.changeset/giant-cars-walk.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-techdocs': patch
+---
+
+Export alpha routes and nav item extension, only available for applications that uses the new Frontend system.
diff --git a/.changeset/giant-cycles-end.md b/.changeset/giant-cycles-end.md
new file mode 100644
index 0000000000..f69938ea25
--- /dev/null
+++ b/.changeset/giant-cycles-end.md
@@ -0,0 +1,7 @@
+---
+'@backstage/plugin-stack-overflow-backend': patch
+---
+
+Deprecate package in favor of the new `@backstage/plugin-search-backend-module-stack-overflow-collator` module.
+
+The search collator `requestParams` option is optional now, so its default value is `{ order: 'desc', sort: 'activity', site: 'stackoverflow' }` as defined in the `Try It` section on the [official Stack Overflow API documentation](https://api.stackexchange.com/docs/questions).
diff --git a/.changeset/good-plums-confess.md b/.changeset/good-plums-confess.md
new file mode 100644
index 0000000000..941adec8b3
--- /dev/null
+++ b/.changeset/good-plums-confess.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-app-api': patch
+---
+
+Refactor internal extension instance system into an app graph.
diff --git a/.changeset/hip-mugs-camp.md b/.changeset/hip-mugs-camp.md
new file mode 100644
index 0000000000..3413b5ac0a
--- /dev/null
+++ b/.changeset/hip-mugs-camp.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-techdocs-backend': patch
+---
+
+Add info about the entity when tech docs fail to build
diff --git a/.changeset/large-forks-arrive.md b/.changeset/large-forks-arrive.md
new file mode 100644
index 0000000000..3490a87128
--- /dev/null
+++ b/.changeset/large-forks-arrive.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-code-coverage-backend': patch
+---
+
+Added support for new backend system
diff --git a/.changeset/long-turkeys-argue.md b/.changeset/long-turkeys-argue.md
new file mode 100644
index 0000000000..f051d42478
--- /dev/null
+++ b/.changeset/long-turkeys-argue.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-scaffolder-backend': patch
+---
+
+Add examples for `github:environment:create` scaffolder action & improve related tests
diff --git a/.changeset/mighty-crews-attack.md b/.changeset/mighty-crews-attack.md
new file mode 100644
index 0000000000..c0b8587fe0
--- /dev/null
+++ b/.changeset/mighty-crews-attack.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-catalog': patch
+---
+
+Initial entity page implementation for new frontend system at `/alpha`, with an overview page enabled by default and the about card available as an optional card.
diff --git a/.changeset/nice-apes-kneel.md b/.changeset/nice-apes-kneel.md
new file mode 100644
index 0000000000..1ecff9d223
--- /dev/null
+++ b/.changeset/nice-apes-kneel.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-techdocs': patch
+---
+
+Added entity page content for the new plugin exported via `/alpha`.
diff --git a/.changeset/nice-pillows-poke.md b/.changeset/nice-pillows-poke.md
new file mode 100644
index 0000000000..64034c39d2
--- /dev/null
+++ b/.changeset/nice-pillows-poke.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-stack-overflow': patch
+---
+
+Migrate package to the new Frontend system, the new module is distributed with a `/alpha` subpath.
diff --git a/.changeset/old-apricots-taste.md b/.changeset/old-apricots-taste.md
new file mode 100644
index 0000000000..544ba11536
--- /dev/null
+++ b/.changeset/old-apricots-taste.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-catalog-react': patch
+---
+
+Added new APIs at the `/alpha` subpath for creating entity page cards and content for the new frontend system.
diff --git a/.changeset/olive-paws-divide.md b/.changeset/olive-paws-divide.md
new file mode 100644
index 0000000000..688b6025bf
--- /dev/null
+++ b/.changeset/olive-paws-divide.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-app-api': patch
+---
+
+Updated `app.extensions` configuration schema.
diff --git a/.changeset/plenty-tigers-argue.md b/.changeset/plenty-tigers-argue.md
new file mode 100644
index 0000000000..ef9e58d63b
--- /dev/null
+++ b/.changeset/plenty-tigers-argue.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-tech-insights-backend': patch
+---
+
+Add `factRetrieverId` to the fact retriever's logger metadata.
diff --git a/.changeset/poor-seahorses-rush.md b/.changeset/poor-seahorses-rush.md
new file mode 100644
index 0000000000..55313f706d
--- /dev/null
+++ b/.changeset/poor-seahorses-rush.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-azure-devops': patch
+---
+
+Consolidated getting the annotation values into a single function to help with future changes
diff --git a/.changeset/quick-roses-move.md b/.changeset/quick-roses-move.md
new file mode 100644
index 0000000000..767ad0919e
--- /dev/null
+++ b/.changeset/quick-roses-move.md
@@ -0,0 +1,6 @@
+---
+'@backstage/dev-utils': patch
+'@backstage/plugin-techdocs': patch
+---
+
+Switched the conditional `react-dom/client` import to use `import(...)` rather than `require(...)`.
diff --git a/.changeset/real-carrots-brake.md b/.changeset/real-carrots-brake.md
new file mode 100644
index 0000000000..e9ae1f0c27
--- /dev/null
+++ b/.changeset/real-carrots-brake.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-search-react': patch
+---
+
+Fixed a rare occurrence where a race in the search bar could throw away user input or cause the clear button not to work.
diff --git a/.changeset/real-jars-yawn.md b/.changeset/real-jars-yawn.md
new file mode 100644
index 0000000000..a89a687971
--- /dev/null
+++ b/.changeset/real-jars-yawn.md
@@ -0,0 +1,5 @@
+---
+'@backstage/create-app': patch
+---
+
+If create app installs dependencies, don't suggest to user that they also need to do it.
diff --git a/.changeset/rich-pugs-chew.md b/.changeset/rich-pugs-chew.md
new file mode 100644
index 0000000000..6c5aa0af62
--- /dev/null
+++ b/.changeset/rich-pugs-chew.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli': patch
+---
+
+The experimental package detection will now ignore packages that don't make `package.json` available.
diff --git a/.changeset/shaggy-buses-beg.md b/.changeset/shaggy-buses-beg.md
new file mode 100644
index 0000000000..d2c9351b5e
--- /dev/null
+++ b/.changeset/shaggy-buses-beg.md
@@ -0,0 +1,9 @@
+---
+'@backstage/plugin-catalog-react': minor
+---
+
+The `UserListPicker` component has undergone improvements to enhance its performance.
+
+The previous implementation inferred the number of owned and starred entities based on the entities available in the `EntityListContext`. The updated version no longer relies on the `EntityListContext` for inference, allowing for better decoupling.
+
+The component now loads the entities' count asynchronously, resulting in improved performance and responsiveness. For this purpose, some of the exported filters such as `EntityTagFilter`, `EntityOwnerFilter`, `EntityLifecycleFilter` and `EntityNamespaceFilter` have now the `getCatalogFilters` method implemented.
diff --git a/.changeset/sixty-tips-argue.md b/.changeset/sixty-tips-argue.md
new file mode 100644
index 0000000000..9fc965a281
--- /dev/null
+++ b/.changeset/sixty-tips-argue.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-plugin-api': patch
+---
+
+Improve the extension boundary component and create a default extension suspense component.
diff --git a/.changeset/strange-queens-deliver.md b/.changeset/strange-queens-deliver.md
new file mode 100644
index 0000000000..ed1d4d0b5b
--- /dev/null
+++ b/.changeset/strange-queens-deliver.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-auth-backend': patch
+---
+
+Reverted the Microsoft auth provider to the previous implementation.
diff --git a/.changeset/strong-taxis-wait.md b/.changeset/strong-taxis-wait.md
new file mode 100644
index 0000000000..13cfa9a1ba
--- /dev/null
+++ b/.changeset/strong-taxis-wait.md
@@ -0,0 +1,5 @@
+---
+'@backstage/backend-app-api': patch
+---
+
+Added a workaround for double `default` wrapping when dynamically importing CommonJS modules with default exports.
diff --git a/.changeset/sweet-countries-share.md b/.changeset/sweet-countries-share.md
new file mode 100644
index 0000000000..e1327a74b8
--- /dev/null
+++ b/.changeset/sweet-countries-share.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-scaffolder-backend': patch
+---
+
+Add examples for `github:webhook` scaffolder action & improve related tests
diff --git a/.changeset/swift-mice-care.md b/.changeset/swift-mice-care.md
new file mode 100644
index 0000000000..1d0d7d8703
--- /dev/null
+++ b/.changeset/swift-mice-care.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli': patch
+---
+
+Added `EXPERIMENTAL_VITE` flag for using [vite](https://vitejs.dev) as dev server instead of Webpack
diff --git a/.changeset/tall-colts-roll.md b/.changeset/tall-colts-roll.md
new file mode 100644
index 0000000000..914a09cdac
--- /dev/null
+++ b/.changeset/tall-colts-roll.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-scaffolder-backend': minor
+---
+
+Allow setting `update: true` in `publish:github:pull-request` scaffolder action
diff --git a/.changeset/tidy-camels-boil.md b/.changeset/tidy-camels-boil.md
new file mode 100644
index 0000000000..6e59f96f34
--- /dev/null
+++ b/.changeset/tidy-camels-boil.md
@@ -0,0 +1,5 @@
+---
+'@backstage/core-app-api': patch
+---
+
+Add component data `core.type` marker for `AppRouter` and `FlatRoutes`.
diff --git a/.changeset/wicked-ties-knock.md b/.changeset/wicked-ties-knock.md
new file mode 100644
index 0000000000..582cb905d0
--- /dev/null
+++ b/.changeset/wicked-ties-knock.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-scaffolder-backend-module-gitlab': patch
+---
+
+Add examples for `gitlab:projectAccessToken:create` scaffolder action & improve related tests
diff --git a/.changeset/wise-waves-approve.md b/.changeset/wise-waves-approve.md
new file mode 100644
index 0000000000..f543f8918c
--- /dev/null
+++ b/.changeset/wise-waves-approve.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli': patch
+---
+
+Ignore `stdin` when spawning backend child process for the `start` command. Fixing an issue where backend startup would hang.
diff --git a/.changeset/wise-weeks-design.md b/.changeset/wise-weeks-design.md
new file mode 100644
index 0000000000..657a8e23d5
--- /dev/null
+++ b/.changeset/wise-weeks-design.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-scaffolder-backend': patch
+---
+
+Add examples for `publish:github:pull-request` scaffolder action & improve related tests
diff --git a/.changeset/young-days-talk.md b/.changeset/young-days-talk.md
new file mode 100644
index 0000000000..b7420c7894
--- /dev/null
+++ b/.changeset/young-days-talk.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-search-react': patch
+---
+
+Use default extensions boundary and suspense on the alpha declarative `createSearchResultListItem` extension factory.
diff --git a/.github/vale/Vocab/Backstage/accept.txt b/.github/vale/Vocab/Backstage/accept.txt
index 1a028d1387..e1c65fec06 100644
--- a/.github/vale/Vocab/Backstage/accept.txt
+++ b/.github/vale/Vocab/Backstage/accept.txt
@@ -430,6 +430,7 @@ Valentina
validator
validators
varchar
+vite
VMware
Vodafone
VPCs
diff --git a/.github/workflows/automate_changeset_feedback.yml b/.github/workflows/automate_changeset_feedback.yml
index 483d59dc8c..eac020cb8e 100644
--- a/.github/workflows/automate_changeset_feedback.yml
+++ b/.github/workflows/automate_changeset_feedback.yml
@@ -34,7 +34,7 @@ jobs:
ref: 'refs/pull/${{ github.event.pull_request.number }}/merge'
- name: fetch base
run: git fetch --depth 1 origin ${{ github.base_ref }}
- - uses: backstage/actions/changeset-feedback@v0.6.4
+ - uses: backstage/actions/changeset-feedback@v0.6.5
name: Generate feedback
with:
diff-ref: 'origin/master'
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 81ea6f96d2..23e81192c0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -40,7 +40,7 @@ jobs:
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
- uses: backstage/actions/yarn-install@v0.6.4
+ uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
@@ -76,7 +76,7 @@ jobs:
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
- uses: backstage/actions/yarn-install@v0.6.4
+ uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
@@ -207,7 +207,7 @@ jobs:
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
- uses: backstage/actions/yarn-install@v0.6.4
+ uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml
index 2874be512c..79cfe9d790 100644
--- a/.github/workflows/cron.yml
+++ b/.github/workflows/cron.yml
@@ -13,7 +13,7 @@ jobs:
with:
egress-policy: audit
- - uses: backstage/actions/cron@v0.6.4
+ - uses: backstage/actions/cron@v0.6.5
with:
app-id: ${{ secrets.BACKSTAGE_GOALIE_APPLICATION_ID }}
private-key: ${{ secrets.BACKSTAGE_GOALIE_PRIVATE_KEY }}
diff --git a/.github/workflows/deploy_docker-image.yml b/.github/workflows/deploy_docker-image.yml
index 30e7badcd0..69776da2d9 100644
--- a/.github/workflows/deploy_docker-image.yml
+++ b/.github/workflows/deploy_docker-image.yml
@@ -31,7 +31,7 @@ jobs:
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
- uses: backstage/actions/yarn-install@v0.6.4
+ uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
diff --git a/.github/workflows/deploy_nightly.yml b/.github/workflows/deploy_nightly.yml
index d5fe9f5a64..7184557e12 100644
--- a/.github/workflows/deploy_nightly.yml
+++ b/.github/workflows/deploy_nightly.yml
@@ -27,7 +27,7 @@ jobs:
node-version: 18.x
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
- uses: backstage/actions/yarn-install@497722d45fb5a35c14c721f63ed74e6ad5f629dd # v0.6.4
+ uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v18.x
diff --git a/.github/workflows/deploy_packages.yml b/.github/workflows/deploy_packages.yml
index f5ebfd7ee2..32f4b139f2 100644
--- a/.github/workflows/deploy_packages.yml
+++ b/.github/workflows/deploy_packages.yml
@@ -68,7 +68,7 @@ jobs:
node-version: ${{ matrix.node-version }}
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
- uses: backstage/actions/yarn-install@v0.6.4
+ uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
@@ -150,7 +150,7 @@ jobs:
node-version: ${{ matrix.node-version }}
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
- uses: backstage/actions/yarn-install@v0.6.4
+ uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
diff --git a/.github/workflows/issue.yaml b/.github/workflows/issue.yaml
index 1c1c028f20..f5150a3a01 100644
--- a/.github/workflows/issue.yaml
+++ b/.github/workflows/issue.yaml
@@ -15,4 +15,4 @@ jobs:
egress-policy: audit
- name: Issue sync
- uses: backstage/actions/issue-sync@v0.6.4
+ uses: backstage/actions/issue-sync@v0.6.5
diff --git a/.github/workflows/pr-review-comment.yaml b/.github/workflows/pr-review-comment.yaml
index e557c85d51..8d66567ff4 100644
--- a/.github/workflows/pr-review-comment.yaml
+++ b/.github/workflows/pr-review-comment.yaml
@@ -40,7 +40,7 @@ jobs:
const prNumber = artifact.name.slice('pr_number-'.length)
core.setOutput('pr-number', prNumber);
- - uses: backstage/actions/re-review@v0.6.4
+ - uses: backstage/actions/re-review@v0.6.5
with:
app-id: ${{ secrets.BACKSTAGE_GOALIE_APPLICATION_ID }}
private-key: ${{ secrets.BACKSTAGE_GOALIE_PRIVATE_KEY }}
diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml
index 8c99f105a9..89193c30ab 100644
--- a/.github/workflows/pr.yaml
+++ b/.github/workflows/pr.yaml
@@ -23,7 +23,7 @@ jobs:
egress-policy: audit
- name: PR sync
- uses: backstage/actions/pr-sync@v0.6.4
+ uses: backstage/actions/pr-sync@v0.6.5
with:
github-token: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }}
app-id: ${{ secrets.BACKSTAGE_GOALIE_APPLICATION_ID }}
diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
index c9078f0086..0f99453bba 100644
--- a/.github/workflows/scorecard.yml
+++ b/.github/workflows/scorecard.yml
@@ -66,6 +66,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: 'Upload to code-scanning'
- uses: github/codeql-action/upload-sarif@fdcae64e1484d349b3366718cdfef3d404390e85 # v2.22.1
+ uses: github/codeql-action/upload-sarif@49abf0ba24d0b7953cb586944e918a0b92074c80 # v2.22.4
with:
sarif_file: results.sarif
diff --git a/.github/workflows/sync_code-formatting.yml b/.github/workflows/sync_code-formatting.yml
index fa8d91f25a..610ad323e2 100644
--- a/.github/workflows/sync_code-formatting.yml
+++ b/.github/workflows/sync_code-formatting.yml
@@ -25,7 +25,7 @@ jobs:
node-version: ${{ matrix.node-version }}
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
- uses: backstage/actions/yarn-install@v0.6.4
+ uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
diff --git a/.github/workflows/sync_snyk-github-issues.yml b/.github/workflows/sync_snyk-github-issues.yml
index 690e2fb76e..2be954746b 100644
--- a/.github/workflows/sync_snyk-github-issues.yml
+++ b/.github/workflows/sync_snyk-github-issues.yml
@@ -24,7 +24,7 @@ jobs:
node-version: 18.x
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
- uses: backstage/actions/yarn-install@v0.6.4
+ uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v18.x
diff --git a/.github/workflows/sync_snyk-monitor.yml b/.github/workflows/sync_snyk-monitor.yml
index 6450b1bd51..84b2b3387b 100644
--- a/.github/workflows/sync_snyk-monitor.yml
+++ b/.github/workflows/sync_snyk-monitor.yml
@@ -58,6 +58,6 @@ jobs:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
NODE_OPTIONS: --max-old-space-size=7168
- name: Upload Snyk report
- uses: github/codeql-action/upload-sarif@v2.21.8
+ uses: github/codeql-action/upload-sarif@v2.22.4
with:
sarif_file: snyk.sarif
diff --git a/.github/workflows/uffizzi-build.yml b/.github/workflows/uffizzi-build.yml
index 3107b87b8f..375c6bdcd2 100644
--- a/.github/workflows/uffizzi-build.yml
+++ b/.github/workflows/uffizzi-build.yml
@@ -34,7 +34,7 @@ jobs:
registry-url: https://registry.npmjs.org/
- name: yarn install
- uses: backstage/actions/yarn-install@v0.6.4
+ uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: linux-v18
diff --git a/.github/workflows/verify_accessibility.yml b/.github/workflows/verify_accessibility.yml
index d53f3834d2..f34f2f5506 100644
--- a/.github/workflows/verify_accessibility.yml
+++ b/.github/workflows/verify_accessibility.yml
@@ -30,7 +30,7 @@ jobs:
with:
node-version: 18.x
- name: yarn install
- uses: backstage/actions/yarn-install@v0.6.4
+ uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v18.x
- name: run Lighthouse CI
diff --git a/.github/workflows/verify_codeql.yml b/.github/workflows/verify_codeql.yml
index 666bd5f0f4..fb29fb78e1 100644
--- a/.github/workflows/verify_codeql.yml
+++ b/.github/workflows/verify_codeql.yml
@@ -55,7 +55,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@v2.21.8
+ uses: github/codeql-action/init@v2.22.4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -66,7 +66,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
- uses: github/codeql-action/autobuild@v2.21.8
+ uses: github/codeql-action/autobuild@v2.22.4
# โน๏ธ Command-line programs to run using the OS shell.
# ๐ https://git.io/JvXDl
@@ -80,4 +80,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v2.21.8
+ uses: github/codeql-action/analyze@v2.22.4
diff --git a/.github/workflows/verify_e2e-kubernetes.yml b/.github/workflows/verify_e2e-kubernetes.yml
index cddd6589d7..ea77609614 100644
--- a/.github/workflows/verify_e2e-kubernetes.yml
+++ b/.github/workflows/verify_e2e-kubernetes.yml
@@ -35,7 +35,7 @@ jobs:
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
- uses: backstage/actions/yarn-install@v0.6.4
+ uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
diff --git a/.github/workflows/verify_e2e-linux.yml b/.github/workflows/verify_e2e-linux.yml
index 744a5d7e74..6b61053628 100644
--- a/.github/workflows/verify_e2e-linux.yml
+++ b/.github/workflows/verify_e2e-linux.yml
@@ -57,7 +57,7 @@ jobs:
node-version: ${{ matrix.node-version }}
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
- uses: backstage/actions/yarn-install@v0.6.4
+ uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
diff --git a/.github/workflows/verify_e2e-windows.yml b/.github/workflows/verify_e2e-windows.yml
index 1385983c24..7ee5e596c5 100644
--- a/.github/workflows/verify_e2e-windows.yml
+++ b/.github/workflows/verify_e2e-windows.yml
@@ -78,7 +78,7 @@ jobs:
uses: browser-actions/setup-chrome@803ef6dfb4fdf22089c9563225d95e4a515820a0 # latest
- name: yarn install
- uses: backstage/actions/yarn-install@v0.6.4
+ uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
diff --git a/.github/workflows/verify_storybook.yml b/.github/workflows/verify_storybook.yml
index f0354df296..e992a2d658 100644
--- a/.github/workflows/verify_storybook.yml
+++ b/.github/workflows/verify_storybook.yml
@@ -42,7 +42,7 @@ jobs:
node-version: ${{ matrix.node-version }}
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
- uses: backstage/actions/yarn-install@v0.6.4
+ uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
- name: storybook yarn install
diff --git a/ADOPTERS.md b/ADOPTERS.md
index b5e19106b7..f0d6e4ed0a 100644
--- a/ADOPTERS.md
+++ b/ADOPTERS.md
@@ -78,7 +78,7 @@ _You can do this by using the [Adopter form](https://info.backstage.spotify.com/
| [Telstra](https://www.telstra.com.au) | [@kiranpatel11](https://github.com/kiranpatel11), [JasonC](https://github.com/JasonC17) | Primary usage: software catalog and templates Emerging usage : TechDocs, Explore Ecosystem, TechRadar, etc |
| [Mosaico](https://www.mosaico.com.br/) | [Wรฉdney Yuri](https://github.com/wedneyyuri),[@tino.milton](https://github.com/miltonjacomini) | A centralized service catalog of our documentation for our service engineers. |
| [Mox Bank](https://www.mox.com/) | [Nick Laqua](https://github.com/nick-laqua-dragon) | "Single pane of glass" developer portal for providing a best-in-class developer experience to our product teams and making Mox the best tech environment in Hongkong ๐ฅฐ๐ |
-| [Keyloop](https://www.keyloop.com/) | [Andre Wanlin](https://github.com/awanlin) | Future-motive Developer Portal to help our teams create technology to make everything about buying and owning a car better. ๐ |
+| [Keyloop](https://www.keyloop.com/) | [Shawn Bruce](https://github.com/sbruce-keyloop) | Future-motive Developer Portal to help our teams create technology to make everything about buying and owning a car better. ๐ |
| [Simply Business](https://sbtech.simplybusiness.co.uk/) | [@addersuk](https://github.com/addersuk), [@LightningStairs](https://github.com/LightningStairs), [@punitcse](https://github.com/punitcse), [@moltenice](https://github.com/moltenice) | Central developer portal to access everything a developer needs such as docs, internal service catalog, and the ability to quickly create a new service from a template. Internally developed Backstage plugins allow us to customise the experience to how we work. |
| [Overwolf](https://www.overwolf.com) | [@tomwolfgang](https://github.com/tomwolfgang) | Dev portal - software catalog, tech-docs, scaffolding |
| [Hotmart](https://www.hotmart.com) | [@fabioviana-hotmart](https://github.com/fabioviana-hotmart) | The main Developers Portal to centralize docs, applications and technical metrics. |
diff --git a/OWNERS.md b/OWNERS.md
index caf5bc4d14..2ea263f3b4 100644
--- a/OWNERS.md
+++ b/OWNERS.md
@@ -145,7 +145,7 @@ Scope: The Scaffolder frontend and backend plugins, and related tooling.
| ------------------------------ | ------------------------- | ----------------------------------------------------- | ------------------------------ |
| Adam Harvey | Cisco | [adamdmharvey](https://github.com/adamdmharvey) | `adamharvey_` |
| Alex Crome | | [afscrome](https://github.com/afscrome) | `afscrome` |
-| Andre Wanlin | Keyloop | [awanlin](https://github.com/awanlin) | `ahhhndre` |
+| Andre Wanlin | Spotify | [awanlin](https://github.com/awanlin) | `ahhhndre` |
| Andrew Thauer | Wealthsimple | [andrewthauer](https://github.com/andrewthauer) | `andrewthauer#3060` |
| Aramis Sennyey | Spotify | [sennyeya](https://github.com/sennyeya) | `Aramis#7984` |
| Brian Fletcher | Roadie.io | [punkle](https://github.com/punkle) | `Brian Fletcher#7051` |
diff --git a/docs/backend-system/architecture/02-backends.md b/docs/backend-system/architecture/02-backends.md
index 4ba76a5af3..c64024d653 100644
--- a/docs/backend-system/architecture/02-backends.md
+++ b/docs/backend-system/architecture/02-backends.md
@@ -23,7 +23,7 @@ import scaffolderPlugin from '@backstage/plugin-scaffolder-backend';
const backend = createBackend();
// Install desired features
-backend.add(import('@backstage/plugin-catalog-backend'));
+backend.add(import('@backstage/plugin-catalog-backend/alpha'));
// Features can also be installed using an explicit reference
backend.add(scaffolderPlugin());
diff --git a/docs/backend-system/building-backends/01-index.md b/docs/backend-system/building-backends/01-index.md
index 7d331b4ec3..c381999429 100644
--- a/docs/backend-system/building-backends/01-index.md
+++ b/docs/backend-system/building-backends/01-index.md
@@ -24,9 +24,9 @@ import { createBackend } from '@backstage/backend-defaults'; // Omitted in the e
const backend = createBackend();
-backend.add(import('@backstage/plugin-app-backend'));
-backend.add(import('@backstage/plugin-catalog-backend'));
-backend.add(import('@backstage/plugin-scaffolder-backend'));
+backend.add(import('@backstage/plugin-app-backend/alpha'));
+backend.add(import('@backstage/plugin-catalog-backend/alpha'));
+backend.add(import('@backstage/plugin-scaffolder-backend/alpha'));
backend.add(
import('@backstage/plugin-catalog-backend-module-scaffolder-entity-model'),
);
@@ -126,8 +126,8 @@ You can now trim down the `src/index.ts` files to only include the plugins and m
```ts
const backend = createBackend();
-backend.add(import('@backstage/plugin-app-backend'));
-backend.add(import('@backstage/plugin-catalog-backend'));
+backend.add(import('@backstage/plugin-app-backend/alpha'));
+backend.add(import('@backstage/plugin-catalog-backend/alpha'));
backend.add(
import('@backstage/plugin-catalog-backend-module-scaffolder-entity-model'),
);
@@ -139,7 +139,7 @@ And `backend-b`, don't forget to clean up dependencies in `package.json` as well
```ts
const backend = createBackend();
-backend.add(import('@backstage/plugin-scaffolder-backend'));
+backend.add(import('@backstage/plugin-scaffolder-backend/alpha'));
backend.start();
```
diff --git a/docs/backend-system/building-backends/08-migrating.md b/docs/backend-system/building-backends/08-migrating.md
index 1a380421c1..1e30d0aa9e 100644
--- a/docs/backend-system/building-backends/08-migrating.md
+++ b/docs/backend-system/building-backends/08-migrating.md
@@ -177,11 +177,10 @@ custom API, so we use a helper function to transform that particular one.
To make additions as mentioned above to the environment, you will start to get
into the weeds of how the backend system wiring works. You'll need to have a
service reference and a service factory that performs the actual creation of
-your service. Please see [the services
-article](../architecture/03-services.md#defining-a-service) to learn how to
-create a service ref and its default factory. You can place that code directly
-in the index file for now if you want, or near the actual implementation class
-in question.
+your service. Please see [the services article](../architecture/03-services.md)
+to learn how to create a service ref and its default factory. You can place that
+code directly in the index file for now if you want, or near the actual implementation
+class in question.
In this example, we'll assume that your added environment field is named
`example`, and the created ref is named `exampleServiceRef`.
@@ -233,7 +232,7 @@ be used in its new form.
```ts title="packages/backend/src/index.ts"
const backend = createBackend();
/* highlight-add-next-line */
-backend.add(import('@backstage/plugin-app-backend'));
+backend.add(import('@backstage/plugin-app-backend/alpha'));
```
If you need to override the app package name, which otherwise defaults to `"app"`,
@@ -248,7 +247,7 @@ A basic installation of the catalog plugin looks as follows.
```ts title="packages/backend/src/index.ts"
const backend = createBackend();
/* highlight-add-start */
-backend.add(import('@backstage/plugin-catalog-backend'));
+backend.add(import('@backstage/plugin-catalog-backend/alpha'));
backend.add(
import('@backstage/plugin-catalog-backend-module-scaffolder-entity-model'),
);
@@ -296,7 +295,7 @@ const catalogModuleCustomExtensions = createBackendModule({
/* highlight-add-end */
const backend = createBackend();
-backend.add(import('@backstage/plugin-catalog-backend'));
+backend.add(import('@backstage/plugin-catalog-backend/alpha'));
backend.add(
import('@backstage/plugin-catalog-backend-module-scaffolder-entity-model'),
);
@@ -390,7 +389,7 @@ A basic installation of the scaffolder plugin looks as follows.
```ts title="packages/backend/src/index.ts"
const backend = createBackend();
/* highlight-add-next-line */
-backend.add(import('@backstage/plugin-scaffolder-backend'));
+backend.add(import('@backstage/plugin-scaffolder-backend/alpha'));
```
If you have other customizations made to `plugins/scaffolder.ts`, such as adding
@@ -429,7 +428,7 @@ const scaffolderModuleCustomExtensions = createBackendModule({
/* highlight-add-end */
const backend = createBackend();
-backend.add(import('@backstage/plugin-scaffolder-backend'));
+backend.add(import('@backstage/plugin-scaffolder-backend/alpha'));
/* highlight-add-next-line */
backend.add(scaffolderModuleCustomExtensions());
```
diff --git a/docs/plugins/integrating-search-into-plugins.md b/docs/plugins/integrating-search-into-plugins.md
index 7fb8e40b20..28c8b410e3 100644
--- a/docs/plugins/integrating-search-into-plugins.md
+++ b/docs/plugins/integrating-search-into-plugins.md
@@ -22,7 +22,7 @@ Imagine you have a plugin that is responsible for storing FAQ snippets in a data
The search platform provides an interface (`DocumentCollatorFactory` from package `@backstage/plugin-search-common`) that allows you to do exactly that. It works by registering each of your entries as a "document" that later represents one search result each.
-> You can always look at a working example, e.g. [StackOverflowQuestionsCollatorFactory](https://github.com/backstage/backstage/blob/master/plugins/stack-overflow-backend/src/search/StackOverflowQuestionsCollatorFactory.ts), if you are unsure or want to follow best practices.
+> You can always look at a working example, e.g. [StackOverflowQuestionsCollatorFactory](https://github.com/backstage/backstage/blob/master/plugins/search-backend-module-stack-overflow-collator/src/collators/StackOverflowQuestionsCollatorFactory.ts), if you are unsure or want to follow best practices.
#### 1. Install collator interface dependencies
diff --git a/docs/releases/v1.9.0-changelog.md b/docs/releases/v1.9.0-changelog.md
index df14c3d34d..07451f6d2c 100644
--- a/docs/releases/v1.9.0-changelog.md
+++ b/docs/releases/v1.9.0-changelog.md
@@ -1299,7 +1299,7 @@
- d3fea4ae0a: Internal fixes to avoid implicit usage of globals
- 3280711113: Updated dependency `msw` to `^0.49.0`.
- 9516b0c355: Added support for sending virtual pageviews on `search` events in order to enable
- Site Search functionality in GA. For more information consult [README](/plugins/analytics-module-ga/README.md#enabling-site-search)
+ Site Search functionality in GA. For more information consult [README](https://github.com/backstage/backstage/blob/master/plugins/analytics-module-ga/README.md#enabling-site-search)
- Updated dependencies
- @backstage/core-plugin-api@1.2.0
- @backstage/core-components@0.12.1
diff --git a/docs/releases/v1.9.0-next.2-changelog.md b/docs/releases/v1.9.0-next.2-changelog.md
index a410da5263..2c6d495641 100644
--- a/docs/releases/v1.9.0-next.2-changelog.md
+++ b/docs/releases/v1.9.0-next.2-changelog.md
@@ -525,7 +525,7 @@
### Patch Changes
- 9516b0c355: Added support for sending virtual pageviews on `search` events in order to enable
- Site Search functionality in GA. For more information consult [README](/plugins/analytics-module-ga/README.md#enabling-site-search)
+ Site Search functionality in GA. For more information consult [README](https://github.com/backstage/backstage/blob/master/plugins/analytics-module-ga/README.md#enabling-site-search)
- Updated dependencies
- @backstage/core-plugin-api@1.2.0-next.2
- @backstage/core-components@0.12.1-next.2
diff --git a/packages/app-next/app-config.yaml b/packages/app-next/app-config.yaml
index 6f6081fece..0c75e1c96c 100644
--- a/packages/app-next/app-config.yaml
+++ b/packages/app-next/app-config.yaml
@@ -4,12 +4,17 @@ app:
routes:
bindings:
plugin.pages.externalRoutes.pageX: plugin.pages.routes.pageX
- # waiting for https://github.com/backstage/backstage/pull/20605
- # catalog.externalRoutes.viewTechDoc: techdocs.routes.docRoot
+ plugin.catalog.externalRoutes.viewTechDoc: plugin.techdocs.routes.docRoot
extensions:
- apis.plugin.graphiql.browse.gitlab: true
+ # Entity page cards
+ - 'entity.cards.about'
+
+ # Entity page content
+ - 'entity.content.techdocs'
+
# scmAuthExtension: >-
# createScmAuthExtension({
# id: 'apis.scmAuth.addons.ghe',
diff --git a/packages/app-next/package.json b/packages/app-next/package.json
index 71d888ee50..5437c66272 100644
--- a/packages/app-next/package.json
+++ b/packages/app-next/package.json
@@ -68,7 +68,6 @@
"@backstage/plugin-search-react": "workspace:^",
"@backstage/plugin-sentry": "workspace:^",
"@backstage/plugin-shortcuts": "workspace:^",
- "@backstage/plugin-stack-overflow": "workspace:^",
"@backstage/plugin-stackstorm": "workspace:^",
"@backstage/plugin-tech-insights": "workspace:^",
"@backstage/plugin-tech-radar": "workspace:^",
diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx
index 4e42d9b268..1275722da4 100644
--- a/packages/app-next/src/App.tsx
+++ b/packages/app-next/src/App.tsx
@@ -29,11 +29,8 @@ import {
createExtension,
createApiExtension,
createExtensionOverrides,
- createPageExtension,
} from '@backstage/frontend-plugin-api';
-import { entityRouteRef } from '@backstage/plugin-catalog-react';
import techdocsPlugin from '@backstage/plugin-techdocs/alpha';
-import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha';
import { homePage } from './HomePage';
import { collectLegacyRoutes } from '@backstage/core-compat-api';
import { FlatRoutes } from '@backstage/core-app-api';
@@ -75,13 +72,6 @@ TODO:
/* app.tsx */
-const entityPageExtension = createPageExtension({
- id: 'catalog:entity',
- defaultPath: '/catalog/:namespace/:kind/:name',
- routeRef: convertLegacyRouteRef(entityRouteRef),
- loader: async () =>
Just a temporary mocked entity page
,
-});
-
const homePageExtension = createExtension({
id: 'myhomepage',
attachTo: { id: 'home', input: 'props' },
@@ -122,12 +112,7 @@ const app = createApp({
homePlugin,
...collectedLegacyPlugins,
createExtensionOverrides({
- extensions: [
- entityPageExtension,
- homePageExtension,
- scmAuthExtension,
- scmIntegrationApi,
- ],
+ extensions: [homePageExtension, scmAuthExtension, scmIntegrationApi],
}),
],
/* Handled through config instead */
diff --git a/packages/app/package.json b/packages/app/package.json
index df48491481..9c6b578cd8 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -93,12 +93,16 @@
"@roadiehq/backstage-plugin-github-insights": "^2.0.5",
"@roadiehq/backstage-plugin-github-pull-requests": "^2.2.7",
"@roadiehq/backstage-plugin-travis-ci": "^2.0.5",
+ "@vitejs/plugin-react": "^4.0.4",
"history": "^5.0.0",
"react": "^18.0.2",
"react-dom": "^18.0.2",
"react-router": "^6.3.0",
"react-router-dom": "^6.3.0",
"react-use": "^17.2.4",
+ "vite": "^4.4.9",
+ "vite-plugin-html": "^3.2.0",
+ "vite-plugin-node-polyfills": "^0.14.1",
"zen-observable": "^0.10.0"
},
"devDependencies": {
diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx
index 81af3b91e7..ecc1f008b5 100644
--- a/packages/app/src/components/catalog/EntityPage.tsx
+++ b/packages/app/src/components/catalog/EntityPage.tsx
@@ -41,6 +41,7 @@ import {
EntityAzurePullRequestsContent,
isAzureDevOpsAvailable,
isAzurePipelinesAvailable,
+ EntityAzureReadmeCard,
} from '@backstage/plugin-azure-devops';
import {
isOctopusDeployAvailable,
@@ -415,6 +416,14 @@ const overviewContent = (
+
+
+
+
+
+
+
+
diff --git a/packages/backend-app-api/src/wiring/BackstageBackend.ts b/packages/backend-app-api/src/wiring/BackstageBackend.ts
index 2b916908de..d523481cf7 100644
--- a/packages/backend-app-api/src/wiring/BackstageBackend.ts
+++ b/packages/backend-app-api/src/wiring/BackstageBackend.ts
@@ -57,7 +57,26 @@ function isPromise(value: unknown | Promise): value is Promise {
}
function unwrapFeature(
- feature: BackendFeature | (() => BackendFeature),
+ feature:
+ | BackendFeature
+ | (() => BackendFeature)
+ | { default: BackendFeature | (() => BackendFeature) },
): BackendFeature {
- return typeof feature === 'function' ? feature() : feature;
+ if (typeof feature === 'function') {
+ return feature();
+ }
+ if ('$$type' in feature) {
+ return feature;
+ }
+ // This is a workaround where default exports get transpiled to `exports['default'] = ...`
+ // in CommonJS modules, which in turn results in a double `{ default: { default: ... } }` nesting
+ // when importing using a dynamic import.
+ // TODO: This is a broader issue than just this piece of code, and should move away from CommonJS.
+ if ('default' in feature) {
+ const defaultFeature = feature.default;
+ return typeof defaultFeature === 'function'
+ ? defaultFeature()
+ : defaultFeature;
+ }
+ return feature;
}
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 419b02d929..689de4b20e 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -40,8 +40,6 @@
"@backstage/integration": "workspace:^",
"@backstage/release-manifests": "workspace:^",
"@backstage/types": "workspace:^",
- "@esbuild-kit/cjs-loader": "^2.4.1",
- "@esbuild-kit/esm-loader": "^2.5.5",
"@manypkg/get-packages": "^1.1.3",
"@octokit/graphql": "^5.0.0",
"@octokit/graphql-schema": "^13.7.0",
@@ -131,6 +129,7 @@
"swc-loader": "^0.2.3",
"tar": "^6.1.12",
"terser-webpack-plugin": "^5.1.3",
+ "tsx": "^3.14.0",
"util": "^0.12.3",
"webpack": "^5.70.0",
"webpack-dev-server": "^4.7.3",
@@ -153,6 +152,7 @@
"@backstage/theme": "workspace:^",
"@types/cross-spawn": "^6.0.2",
"@types/diff": "^5.0.0",
+ "@types/ejs": "^3.1.3",
"@types/express": "^4.17.6",
"@types/fs-extra": "^9.0.1",
"@types/http-proxy": "^1.17.4",
@@ -174,11 +174,27 @@
"type-fest": "^2.19.0"
},
"peerDependencies": {
- "@microsoft/api-extractor": "^7.21.2"
+ "@microsoft/api-extractor": "^7.21.2",
+ "@vitejs/plugin-react": "^4.0.4",
+ "vite": "^4.4.9",
+ "vite-plugin-html": "^3.2.0",
+ "vite-plugin-node-polyfills": "^0.14.1"
},
"peerDependenciesMeta": {
"@microsoft/api-extractor": {
"optional": true
+ },
+ "@vitejs/plugin-react": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ },
+ "vite-plugin-html": {
+ "optional": true
+ },
+ "vite-plugin-node-polyfills": {
+ "optional": true
}
},
"files": [
diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts
index 534ffe8c58..542cc83ce9 100644
--- a/packages/cli/src/lib/bundler/config.ts
+++ b/packages/cli/src/lib/bundler/config.ts
@@ -39,6 +39,7 @@ import { runPlain } from '../run';
import { transforms } from './transforms';
import { version } from '../../lib/version';
import yn from 'yn';
+import { hasReactDomClient } from './hasReactDomClient';
const BUILD_CACHE_ENV_VAR = 'BACKSTAGE_CLI_EXPERIMENTAL_BUILD_CACHE';
@@ -81,15 +82,6 @@ async function readBuildInfo() {
};
}
-function hasReactDomClient() {
- try {
- require.resolve('react-dom/client');
- return true;
- } catch {
- return false;
- }
-}
-
export async function createConfig(
paths: BundlingPaths,
options: BundlingOptions,
diff --git a/packages/cli/src/lib/bundler/hasReactDomClient.ts b/packages/cli/src/lib/bundler/hasReactDomClient.ts
new file mode 100644
index 0000000000..e7ef7bbac9
--- /dev/null
+++ b/packages/cli/src/lib/bundler/hasReactDomClient.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export function hasReactDomClient() {
+ try {
+ require.resolve('react-dom/client');
+ return true;
+ } catch {
+ return false;
+ }
+}
diff --git a/packages/cli/src/lib/bundler/packageDetection.ts b/packages/cli/src/lib/bundler/packageDetection.ts
index 2095fe23e7..3aa0955c06 100644
--- a/packages/cli/src/lib/bundler/packageDetection.ts
+++ b/packages/cli/src/lib/bundler/packageDetection.ts
@@ -77,24 +77,28 @@ async function detectPackages(
return [];
}
- const depPackageJson: BackstagePackageJson = require(require.resolve(
- `${depName}/package.json`,
- { paths: [targetPath] },
- ));
- if (
- ['frontend-plugin', 'frontend-plugin-module'].includes(
- depPackageJson.backstage?.role ?? '',
- )
- ) {
- // Include alpha entry point if available. If there's no default export it will be ignored
- const exp = depPackageJson.exports;
- if (exp && typeof exp === 'object' && './alpha' in exp) {
- return [
- { name: depName, import: depName },
- { name: depName, export: './alpha', import: `${depName}/alpha` },
- ];
+ try {
+ const depPackageJson: BackstagePackageJson = require(require.resolve(
+ `${depName}/package.json`,
+ { paths: [targetPath] },
+ ));
+ if (
+ ['frontend-plugin', 'frontend-plugin-module'].includes(
+ depPackageJson.backstage?.role ?? '',
+ )
+ ) {
+ // Include alpha entry point if available. If there's no default export it will be ignored
+ const exp = depPackageJson.exports;
+ if (exp && typeof exp === 'object' && './alpha' in exp) {
+ return [
+ { name: depName, import: depName },
+ { name: depName, export: './alpha', import: `${depName}/alpha` },
+ ];
+ }
+ return [{ name: depName, import: depName }];
}
- return [{ name: depName, import: depName }];
+ } catch {
+ /* ignore packages that don't make package.json available */
}
return [];
});
diff --git a/packages/cli/src/lib/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts
index 78f6bb7823..bd70b2b074 100644
--- a/packages/cli/src/lib/bundler/server.ts
+++ b/packages/cli/src/lib/bundler/server.ts
@@ -34,6 +34,7 @@ import { createConfig, resolveBaseUrl } from './config';
import { createDetectedModulesEntryPoint } from './packageDetection';
import { resolveBundlingPaths } from './paths';
import { ServeOptions } from './types';
+import { hasReactDomClient } from './hasReactDomClient';
export async function serveBundle(options: ServeOptions) {
const paths = resolveBundlingPaths(options);
@@ -77,7 +78,9 @@ export async function serveBundle(options: ServeOptions) {
const { name } = await fs.readJson(libPaths.resolveTarget('package.json'));
- let server: WebpackDevServer | undefined = undefined;
+ let webpackServer: WebpackDevServer | undefined = undefined;
+ let viteServer: import('vite').ViteDevServer | undefined = undefined;
+
let latestFrontendAppConfigs: AppConfig[] = [];
const cliConfig = await loadCliConfig({
@@ -86,7 +89,9 @@ export async function serveBundle(options: ServeOptions) {
withFilteredKeys: true,
watch(appConfigs) {
latestFrontendAppConfigs = appConfigs;
- server?.invalidate();
+
+ webpackServer?.invalidate();
+ viteServer?.restart();
},
});
latestFrontendAppConfigs = cliConfig.frontendAppConfigs;
@@ -123,7 +128,8 @@ export async function serveBundle(options: ServeOptions) {
config: fullConfig,
targetPath: paths.targetPath,
watch() {
- server?.invalidate();
+ webpackServer?.invalidate();
+ viteServer?.restart();
},
});
@@ -139,64 +145,111 @@ export async function serveBundle(options: ServeOptions) {
additionalEntryPoints: detectedModulesEntryPoint,
});
- const compiler = webpack(config);
-
- server = new WebpackDevServer(
- {
- hot: !process.env.CI,
- devMiddleware: {
- publicPath: config.output?.publicPath as string,
- stats: 'errors-warnings',
+ if (process.env.EXPERIMENTAL_VITE) {
+ const { default: vite } = await import('vite');
+ const { default: viteReact } = await import('@vitejs/plugin-react');
+ const { nodePolyfills: viteNodePolyfills } = await import(
+ 'vite-plugin-node-polyfills'
+ );
+ const { createHtmlPlugin: viteHtml } = await import('vite-plugin-html');
+ viteServer = await vite.createServer({
+ define: {
+ global: 'window',
+ 'process.argv': JSON.stringify(process.argv),
+ 'process.env.APP_CONFIG': JSON.stringify(cliConfig.frontendAppConfigs),
+ // This allows for conditional imports of react-dom/client, since there's no way
+ // to check for presence of it in source code without module resolution errors.
+ 'process.env.HAS_REACT_DOM_CLIENT': JSON.stringify(hasReactDomClient()),
},
- static: paths.targetPublic
- ? {
- publicPath: config.output?.publicPath as string,
- directory: paths.targetPublic,
- }
- : undefined,
- historyApiFallback: {
- // 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`,
+ plugins: [
+ viteReact(),
+ viteNodePolyfills(),
+ viteHtml({
+ entry: paths.targetEntry,
+ // todo(blam): we should look at contributing to thPe plugin here
+ // to support absolute paths, but works in the interim at least.
+ template: 'public/index.html',
+ inject: {
+ data: {
+ config: frontendConfig,
+ publicPath: config.output?.publicPath,
+ },
+ },
+ }),
+ ],
+ server: {
+ host,
+ port,
},
- https:
- url.protocol === 'https:'
- ? {
- cert: fullConfig.getString('app.https.certificate.cert'),
- key: fullConfig.getString('app.https.certificate.key'),
- }
- : false,
- host,
- port,
- proxy: targetPkg.proxy,
- // When the dev server is behind a proxy, the host and public hostname differ
- allowedHosts: [url.hostname],
- client: {
- webSocketURL: 'auto://0.0.0.0:0/ws',
- },
- } as any,
- compiler as any,
- );
-
- await new Promise((resolve, reject) => {
- server?.startCallback((err?: Error) => {
- if (err) {
- reject(err);
- return;
- }
-
- openBrowser(url.href);
- resolve();
+ publicDir: paths.targetPublic,
+ root: paths.targetPath,
});
+ } else {
+ const compiler = webpack(config);
+
+ webpackServer = new WebpackDevServer(
+ {
+ hot: !process.env.CI,
+ devMiddleware: {
+ publicPath: config.output?.publicPath as string,
+ stats: 'errors-warnings',
+ },
+ static: paths.targetPublic
+ ? {
+ publicPath: config.output?.publicPath as string,
+ directory: paths.targetPublic,
+ }
+ : undefined,
+ historyApiFallback: {
+ // 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:'
+ ? {
+ cert: fullConfig.getString('app.https.certificate.cert'),
+ key: fullConfig.getString('app.https.certificate.key'),
+ }
+ : false,
+ host,
+ port,
+ proxy: targetPkg.proxy,
+ // When the dev server is behind a proxy, the host and public hostname differ
+ allowedHosts: [url.hostname],
+ client: {
+ webSocketURL: 'auto://0.0.0.0:0/ws',
+ },
+ },
+ compiler,
+ );
+ }
+
+ await viteServer?.listen();
+ await new Promise(async (resolve, reject) => {
+ if (webpackServer) {
+ webpackServer.startCallback((err?: Error) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ resolve();
+ });
+ } else {
+ resolve();
+ }
});
+ openBrowser(url.href);
+
const waitForExit = async () => {
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, () => {
- server?.close();
+ webpackServer?.close();
+ viteServer?.close();
// exit instead of resolve. The process is shutting down and resolving a promise here logs an error
process.exit();
});
diff --git a/packages/cli/src/lib/experimental/startBackendExperimental.ts b/packages/cli/src/lib/experimental/startBackendExperimental.ts
index c4271deb3d..af6e33a1e4 100644
--- a/packages/cli/src/lib/experimental/startBackendExperimental.ts
+++ b/packages/cli/src/lib/experimental/startBackendExperimental.ts
@@ -27,11 +27,14 @@ import { isAbsolute as isAbsolutePath } from 'path';
import { paths } from '../paths';
import spawn from 'cross-spawn';
+const [nodeMajor, nodeMinor] = process.versions.node.split('.').map(Number);
+const supportsModuleLoaderRegister = nodeMajor >= 20 && nodeMinor >= 6;
+
const loaderArgs = [
'--require',
- require.resolve('@esbuild-kit/cjs-loader'),
- '--loader',
- pathToFileURL(require.resolve('@esbuild-kit/esm-loader')).toString(), // Windows prefers a URL here
+ require.resolve('tsx/preflight'),
+ supportsModuleLoaderRegister ? '--import' : '--loader',
+ pathToFileURL(require.resolve('tsx')).toString(), // Windows prefers a URL here
];
export async function startBackendExperimental(options: BackendServeOptions) {
@@ -95,7 +98,7 @@ export async function startBackendExperimental(options: BackendServeOptions) {
process.execPath,
[...loaderArgs, ...optionArgs, options.entry, ...userArgs],
{
- stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
+ stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
env: {
...process.env,
BACKSTAGE_CLI_CHANNEL: '1',
diff --git a/packages/config-loader/src/sources/ConfigSources.test.ts b/packages/config-loader/src/sources/ConfigSources.test.ts
index 43feb3ca27..f095bbec42 100644
--- a/packages/config-loader/src/sources/ConfigSources.test.ts
+++ b/packages/config-loader/src/sources/ConfigSources.test.ts
@@ -95,6 +95,14 @@ describe('ConfigSources', () => {
),
).toEqual([{ name: 'FileConfigSource', path: '/config.yaml' }]);
+ expect(
+ mergeSources(
+ ConfigSources.defaultForTargets({
+ targets: [{ type: 'path', target: 'config.yaml' }],
+ }),
+ ),
+ ).toEqual([{ name: 'FileConfigSource', path: resolvePath('config.yaml') }]);
+
const subFunc = async () => undefined;
expect(
mergeSources(
@@ -172,8 +180,8 @@ describe('ConfigSources', () => {
}),
),
).toEqual([
- { name: 'FileConfigSource', path: 'a.yaml' },
- { name: 'FileConfigSource', path: 'b.yaml' },
+ { name: 'FileConfigSource', path: resolvePath('a.yaml') },
+ { name: 'FileConfigSource', path: resolvePath('b.yaml') },
{ name: 'EnvConfigSource', env: { HOME: '/' } },
]);
});
diff --git a/packages/config-loader/src/sources/ConfigSources.ts b/packages/config-loader/src/sources/ConfigSources.ts
index 43123f87ea..03fc6d83d6 100644
--- a/packages/config-loader/src/sources/ConfigSources.ts
+++ b/packages/config-loader/src/sources/ConfigSources.ts
@@ -161,7 +161,7 @@ export class ConfigSources {
}
return FileConfigSource.create({
watch: options.watch,
- path: arg.target,
+ path: resolvePath(arg.target),
substitutionFunc: options.substitutionFunc,
});
});
diff --git a/packages/core-app-api/src/app/AppRouter.tsx b/packages/core-app-api/src/app/AppRouter.tsx
index 9d07be1ffd..7481f6662a 100644
--- a/packages/core-app-api/src/app/AppRouter.tsx
+++ b/packages/core-app-api/src/app/AppRouter.tsx
@@ -16,6 +16,7 @@
import React, { useContext, ReactNode, ComponentType, useState } from 'react';
import {
+ attachComponentData,
ConfigApi,
configApiRef,
IdentityApi,
@@ -186,3 +187,5 @@ export function AppRouter(props: AppRouterProps) {
);
}
+
+attachComponentData(AppRouter, 'core.type', 'AppRouter');
diff --git a/packages/core-app-api/src/routing/FlatRoutes.tsx b/packages/core-app-api/src/routing/FlatRoutes.tsx
index abb7fe9432..46c2270d0d 100644
--- a/packages/core-app-api/src/routing/FlatRoutes.tsx
+++ b/packages/core-app-api/src/routing/FlatRoutes.tsx
@@ -16,7 +16,11 @@
import React, { ReactNode, useMemo } from 'react';
import { useRoutes } from 'react-router-dom';
-import { useApp, useElementFilter } from '@backstage/core-plugin-api';
+import {
+ attachComponentData,
+ useApp,
+ useElementFilter,
+} from '@backstage/core-plugin-api';
import { isReactRouterBeta } from '../app/isReactRouterBeta';
let warned = false;
@@ -115,3 +119,5 @@ export const FlatRoutes = (props: FlatRoutesProps): JSX.Element | null => {
return useRoutes(withNotFound);
};
+
+attachComponentData(FlatRoutes, 'core.type', 'FlatRoutes');
diff --git a/packages/core-compat-api/api-report.md b/packages/core-compat-api/api-report.md
index 5175c76ac8..3dd2eb6b22 100644
--- a/packages/core-compat-api/api-report.md
+++ b/packages/core-compat-api/api-report.md
@@ -6,11 +6,18 @@
///
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
+import { ExtensionOverrides } from '@backstage/frontend-plugin-api';
+import { default as React_2 } from 'react';
// @public (undocumented)
export function collectLegacyRoutes(
flatRoutesElement: JSX.Element,
): BackstagePlugin[];
+// @public (undocumented)
+export function convertLegacyApp(
+ rootElement: React_2.JSX.Element,
+): (ExtensionOverrides | BackstagePlugin)[];
+
// (No @packageDocumentation comment for this package)
```
diff --git a/packages/core-compat-api/src/collectLegacyRoutes.test.tsx b/packages/core-compat-api/src/collectLegacyRoutes.test.tsx
index 486c373991..85fed83e8c 100644
--- a/packages/core-compat-api/src/collectLegacyRoutes.test.tsx
+++ b/packages/core-compat-api/src/collectLegacyRoutes.test.tsx
@@ -30,6 +30,7 @@ describe('collectLegacyRoutes', () => {
} />
} />
} />
+ } />
,
);
@@ -85,6 +86,12 @@ describe('collectLegacyRoutes', () => {
disabled: false,
defaultConfig: { path: 'puppetdb' },
},
+ {
+ id: 'plugin.puppetDb.page2',
+ attachTo: { id: 'core.routes', input: 'routes' },
+ disabled: false,
+ defaultConfig: { path: 'puppetdb' },
+ },
{
id: 'apis.plugin.puppetdb.service',
attachTo: { id: 'core', input: 'apis' },
diff --git a/packages/core-compat-api/src/collectLegacyRoutes.tsx b/packages/core-compat-api/src/collectLegacyRoutes.tsx
index 2b149e6285..8178de2876 100644
--- a/packages/core-compat-api/src/collectLegacyRoutes.tsx
+++ b/packages/core-compat-api/src/collectLegacyRoutes.tsx
@@ -62,7 +62,10 @@ Existing tasks:
export function collectLegacyRoutes(
flatRoutesElement: JSX.Element,
): BackstagePlugin[] {
- const results = new Array();
+ const createdPluginIds = new Map<
+ LegacyBackstagePlugin,
+ Extension[]
+ >();
React.Children.forEach(
flatRoutesElement.props.children,
@@ -93,13 +96,18 @@ export function collectLegacyRoutes(
);
const pluginId = plugin.getId();
- const path: string = route.props.path;
- const detectedExtensions = new Array>();
+ const detectedExtensions =
+ createdPluginIds.get(plugin) ?? new Array>();
+ createdPluginIds.set(plugin, detectedExtensions);
+
+ const path: string = route.props.path;
detectedExtensions.push(
createPageExtension({
- id: `plugin.${pluginId}.page`,
+ id: `plugin.${pluginId}.page${
+ detectedExtensions.length ? detectedExtensions.length + 1 : ''
+ }`,
defaultPath: path[0] === '/' ? path.slice(1) : path,
routeRef: routeRef ? convertLegacyRouteRef(routeRef) : undefined,
@@ -115,23 +123,20 @@ export function collectLegacyRoutes(
),
}),
);
+ },
+ );
- detectedExtensions.push(
+ return Array.from(createdPluginIds).map(([plugin, extensions]) =>
+ createPlugin({
+ id: plugin.getId(),
+ extensions: [
+ ...extensions,
...Array.from(plugin.getApis()).map(factory =>
createApiExtension({
factory,
}),
),
- );
-
- results.push(
- createPlugin({
- id: plugin.getId(),
- extensions: detectedExtensions,
- }),
- );
- },
+ ],
+ }),
);
-
- return results;
}
diff --git a/packages/core-compat-api/src/convertLegacyApp.test.tsx b/packages/core-compat-api/src/convertLegacyApp.test.tsx
new file mode 100644
index 0000000000..0f1f6145c9
--- /dev/null
+++ b/packages/core-compat-api/src/convertLegacyApp.test.tsx
@@ -0,0 +1,129 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { AppRouter, FlatRoutes } from '@backstage/core-app-api';
+import { PuppetDbPage } from '@backstage/plugin-puppetdb';
+import { StackstormPage } from '@backstage/plugin-stackstorm';
+import { ScoreBoardPage } from '@oriflame/backstage-plugin-score-card';
+import React, { ReactNode } from 'react';
+import { Route } from 'react-router-dom';
+import { convertLegacyApp } from './convertLegacyApp';
+
+const Root = ({ children }: { children: ReactNode }) => <>{children}>;
+
+describe('convertLegacyApp', () => {
+ it('should find and extract root and routes', () => {
+ const collected = convertLegacyApp(
+ <>
+
+
+
+
+
+
+ } />
+ } />
+ } />
+ } />
+
+
+
+ >,
+ );
+
+ expect(
+ collected.map((p: any /* TODO */) => ({
+ id: p.id,
+ extensions: p.extensions.map((e: any) => ({
+ id: e.id,
+ attachTo: e.attachTo,
+ disabled: e.disabled,
+ defaultConfig: e.configSchema?.parse({}),
+ })),
+ })),
+ ).toEqual([
+ {
+ id: 'score-card',
+ extensions: [
+ {
+ id: 'plugin.score-card.page',
+ attachTo: { id: 'core.routes', input: 'routes' },
+ disabled: false,
+ defaultConfig: { path: 'score-board' },
+ },
+ {
+ id: 'apis.plugin.scoringdata.service',
+ attachTo: { id: 'core', input: 'apis' },
+ disabled: false,
+ },
+ ],
+ },
+ {
+ id: 'stackstorm',
+ extensions: [
+ {
+ id: 'plugin.stackstorm.page',
+ attachTo: { id: 'core.routes', input: 'routes' },
+ disabled: false,
+ defaultConfig: { path: 'stackstorm' },
+ },
+ {
+ id: 'apis.plugin.stackstorm.service',
+ attachTo: { id: 'core', input: 'apis' },
+ disabled: false,
+ },
+ ],
+ },
+ {
+ id: 'puppetDb',
+ extensions: [
+ {
+ id: 'plugin.puppetDb.page',
+ attachTo: { id: 'core.routes', input: 'routes' },
+ disabled: false,
+ defaultConfig: { path: 'puppetdb' },
+ },
+ {
+ id: 'plugin.puppetDb.page2',
+ attachTo: { id: 'core.routes', input: 'routes' },
+ disabled: false,
+ defaultConfig: { path: 'puppetdb' },
+ },
+ {
+ id: 'apis.plugin.puppetdb.service',
+ attachTo: { id: 'core', input: 'apis' },
+ disabled: false,
+ },
+ ],
+ },
+ {
+ id: undefined,
+ extensions: [
+ {
+ id: 'core.layout',
+ attachTo: { id: 'core', input: 'root' },
+ disabled: false,
+ },
+ {
+ id: 'core.nav',
+ attachTo: { id: 'core.layout', input: 'nav' },
+ disabled: true,
+ },
+ ],
+ },
+ ]);
+ });
+});
diff --git a/packages/core-compat-api/src/convertLegacyApp.ts b/packages/core-compat-api/src/convertLegacyApp.ts
new file mode 100644
index 0000000000..59cb136c65
--- /dev/null
+++ b/packages/core-compat-api/src/convertLegacyApp.ts
@@ -0,0 +1,139 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React, {
+ Children,
+ Fragment,
+ ReactElement,
+ ReactNode,
+ isValidElement,
+} from 'react';
+import {
+ BackstagePlugin,
+ ExtensionOverrides,
+ coreExtensionData,
+ createExtension,
+ createExtensionInput,
+ createExtensionOverrides,
+} from '@backstage/frontend-plugin-api';
+import { getComponentData } from '@backstage/core-plugin-api';
+import { collectLegacyRoutes } from './collectLegacyRoutes';
+
+function selectChildren(
+ rootNode: ReactNode,
+ selector?: (element: ReactElement<{ children?: ReactNode }>) => boolean,
+ strictError?: string,
+): Array> {
+ return Children.toArray(rootNode).flatMap(node => {
+ if (!isValidElement<{ children?: ReactNode }>(node)) {
+ return [];
+ }
+
+ if (node.type === Fragment) {
+ return selectChildren(node.props.children, selector, strictError);
+ }
+
+ if (selector === undefined || selector(node)) {
+ return [node];
+ }
+
+ if (strictError) {
+ throw new Error(strictError);
+ }
+
+ return selectChildren(node.props.children, selector, strictError);
+ });
+}
+
+/** @public */
+export function convertLegacyApp(
+ rootElement: React.JSX.Element,
+): (ExtensionOverrides | BackstagePlugin)[] {
+ const appRouterEls = selectChildren(
+ rootElement,
+ el => getComponentData(el, 'core.type') === 'AppRouter',
+ );
+ if (appRouterEls.length !== 1) {
+ throw new Error(
+ "Failed to convert legacy app, AppRouter element could not been found. Make sure it's at the top level of the App element tree",
+ );
+ }
+
+ const rootEls = selectChildren(
+ appRouterEls[0].props.children,
+ el =>
+ Boolean(el.props.children) &&
+ selectChildren(
+ el.props.children,
+ innerEl => getComponentData(innerEl, 'core.type') === 'FlatRoutes',
+ ).length === 1,
+ );
+ if (rootEls.length !== 1) {
+ throw new Error(
+ "Failed to convert legacy app, Root element containing FlatRoutes could not been found. Make sure it's within the AppRouter element of the App element tree",
+ );
+ }
+ const [rootEl] = rootEls;
+
+ const routesEls = selectChildren(
+ rootEls[0].props.children,
+ el => getComponentData(el, 'core.type') === 'FlatRoutes',
+ );
+ if (routesEls.length !== 1) {
+ throw new Error(
+ 'Unexpectedly failed to find FlatRoutes in app element tree',
+ );
+ }
+ const [routesEl] = routesEls;
+
+ const CoreLayoutOverride = createExtension({
+ id: 'core.layout',
+ attachTo: { id: 'core', input: 'root' },
+ inputs: {
+ content: createExtensionInput(
+ {
+ element: coreExtensionData.reactElement,
+ },
+ { singleton: true },
+ ),
+ },
+ output: {
+ element: coreExtensionData.reactElement,
+ },
+ factory({ bind, inputs }) {
+ // Clone the root element, this replaces the FlatRoutes declared in the app with out content input
+ bind({
+ element: React.cloneElement(rootEl, undefined, inputs.content.element),
+ });
+ },
+ });
+ const CoreNavOverride = createExtension({
+ id: 'core.nav',
+ attachTo: { id: 'core.layout', input: 'nav' },
+ output: {},
+ factory() {},
+ disabled: true,
+ });
+
+ const collectedRoutes = collectLegacyRoutes(routesEl);
+
+ return [
+ ...collectedRoutes,
+ createExtensionOverrides({
+ extensions: [CoreLayoutOverride, CoreNavOverride],
+ }),
+ ];
+}
diff --git a/packages/core-compat-api/src/index.ts b/packages/core-compat-api/src/index.ts
index 2181b2fa9a..e5f61119a3 100644
--- a/packages/core-compat-api/src/index.ts
+++ b/packages/core-compat-api/src/index.ts
@@ -14,3 +14,4 @@
* limitations under the License.
*/
export { collectLegacyRoutes } from './collectLegacyRoutes';
+export { convertLegacyApp } from './convertLegacyApp';
diff --git a/packages/core-components/src/components/TabbedLayout/RoutedTabs.tsx b/packages/core-components/src/components/TabbedLayout/RoutedTabs.tsx
index 2a0b0eae28..2c727efe1b 100644
--- a/packages/core-components/src/components/TabbedLayout/RoutedTabs.tsx
+++ b/packages/core-components/src/components/TabbedLayout/RoutedTabs.tsx
@@ -27,8 +27,8 @@ import { SubRoute } from './types';
export function useSelectedSubRoute(subRoutes: SubRoute[]): {
index: number;
- route: SubRoute;
- element: JSX.Element;
+ route?: SubRoute;
+ element?: JSX.Element;
} {
const params = useParams();
@@ -44,7 +44,7 @@ export function useSelectedSubRoute(subRoutes: SubRoute[]): {
b.path.replace(/\/\*$/, '').localeCompare(a.path.replace(/\/\*$/, '')),
);
- const element = useRoutes(sortedRoutes) ?? subRoutes[0].children;
+ const element = useRoutes(sortedRoutes) ?? subRoutes[0]?.children;
// TODO(Rugvip): Once we only support v6 stable we can always prefix
// This avoids having a double / prefix for react-router v6 beta, which in turn breaks
@@ -98,7 +98,7 @@ export function RoutedTabs(props: { routes: SubRoute[] }) {
onChange={onTabChange}
/>
-
+
{element}
>
diff --git a/packages/create-app/src/createApp.ts b/packages/create-app/src/createApp.ts
index 09cede933d..af6b0c9c70 100644
--- a/packages/create-app/src/createApp.ts
+++ b/packages/create-app/src/createApp.ts
@@ -129,7 +129,7 @@ export default async (opts: OptionValues): Promise => {
);
Task.log();
Task.section('All set! Now you might want to');
- if (!opts.skipInstall) {
+ if (opts.skipInstall) {
Task.log(
` Install the dependencies: ${chalk.cyan(
`cd ${opts.path ?? answers.name} && yarn install`,
diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx
index e45ba649f9..ccb1cca2e8 100644
--- a/packages/dev-utils/src/devApp/render.tsx
+++ b/packages/dev-utils/src/devApp/render.tsx
@@ -49,11 +49,13 @@ import { createRoutesFromChildren, Route } from 'react-router-dom';
import { SidebarThemeSwitcher } from './SidebarThemeSwitcher';
import 'react-dom';
-let ReactDOM: typeof import('react-dom') | typeof import('react-dom/client');
+let ReactDOMPromise: Promise<
+ typeof import('react-dom') | typeof import('react-dom/client')
+>;
if (process.env.HAS_REACT_DOM_CLIENT) {
- ReactDOM = require('react-dom/client');
+ ReactDOMPromise = import('react-dom/client');
} else {
- ReactDOM = require('react-dom');
+ ReactDOMPromise = import('react-dom');
}
export function isReactRouterBeta(): boolean {
@@ -242,11 +244,15 @@ export class DevAppBuilder {
window.location.pathname = this.defaultPage;
}
- if ('createRoot' in ReactDOM) {
- ReactDOM.createRoot(document.getElementById('root')!).render();
- } else {
- ReactDOM.render(, document.getElementById('root'));
- }
+ ReactDOMPromise.then(ReactDOM => {
+ if ('createRoot' in ReactDOM) {
+ ReactDOM.createRoot(document.getElementById('root')!).render(
+ ,
+ );
+ } else {
+ ReactDOM.render(, document.getElementById('root'));
+ }
+ });
}
}
diff --git a/packages/frontend-app-api/config.d.ts b/packages/frontend-app-api/config.d.ts
index 9ec020fa56..28355f5376 100644
--- a/packages/frontend-app-api/config.d.ts
+++ b/packages/frontend-app-api/config.d.ts
@@ -34,17 +34,17 @@ export interface Config {
/**
* @deepVisibility frontend
*/
- extensions?:
+ extensions?: Array<
| string
| {
[extensionId: string]:
| boolean
- | string
| {
- at?: string;
- extension?: string;
+ attachTo?: { id: string; input: string };
+ disabled?: boolean;
config?: unknown;
};
- };
+ }
+ >;
};
}
diff --git a/packages/frontend-app-api/src/extensions/Core.tsx b/packages/frontend-app-api/src/extensions/Core.tsx
index 6efe3984dc..f48aa2ea6b 100644
--- a/packages/frontend-app-api/src/extensions/Core.tsx
+++ b/packages/frontend-app-api/src/extensions/Core.tsx
@@ -22,7 +22,7 @@ import {
export const Core = createExtension({
id: 'core',
- attachTo: { id: 'root', input: 'default' },
+ attachTo: { id: 'root', input: 'default' }, // ignored
inputs: {
apis: createExtensionInput({
api: coreExtensionData.apiFactory,
diff --git a/packages/frontend-app-api/src/graph/createAppGraph.test.ts b/packages/frontend-app-api/src/graph/createAppGraph.test.ts
new file mode 100644
index 0000000000..2c9f062199
--- /dev/null
+++ b/packages/frontend-app-api/src/graph/createAppGraph.test.ts
@@ -0,0 +1,123 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ createExtension,
+ createExtensionOverrides,
+ createPlugin,
+} from '@backstage/frontend-plugin-api';
+import { MockConfigApi } from '@backstage/test-utils';
+import { createAppGraph } from './createAppGraph';
+
+const extBase = {
+ id: 'test',
+ attachTo: { id: 'core', input: 'root' },
+ output: {},
+ factory() {},
+};
+
+describe('createAppGraph', () => {
+ it('throws an error when a core extension is parametrized', () => {
+ const config = new MockConfigApi({
+ app: {
+ extensions: [
+ {
+ core: {},
+ },
+ ],
+ },
+ });
+ const features = [
+ createPlugin({
+ id: 'plugin',
+ extensions: [],
+ }),
+ ];
+ expect(() =>
+ createAppGraph({ features, config, builtinExtensions: [] }),
+ ).toThrow("Configuration of the 'core' extension is forbidden");
+ });
+
+ it('throws an error when a core extension is overridden', () => {
+ const config = new MockConfigApi({});
+ const features = [
+ createPlugin({
+ id: 'plugin',
+ extensions: [
+ createExtension({
+ id: 'core',
+ attachTo: { id: 'core.routes', input: 'route' },
+ inputs: {},
+ output: {},
+ factory() {},
+ }),
+ ],
+ }),
+ ];
+ expect(() =>
+ createAppGraph({ features, config, builtinExtensions: [] }),
+ ).toThrow(
+ "It is forbidden to override the following extension(s): 'core', which is done by the following plugin(s): 'plugin'",
+ );
+ });
+
+ it('throws an error when duplicated extensions are detected', () => {
+ const config = new MockConfigApi({});
+
+ const ExtensionA = createExtension({ ...extBase, id: 'A' });
+
+ const ExtensionB = createExtension({ ...extBase, id: 'B' });
+
+ const PluginA = createPlugin({
+ id: 'A',
+ extensions: [ExtensionA, ExtensionA],
+ });
+
+ const PluginB = createPlugin({
+ id: 'B',
+ extensions: [ExtensionA, ExtensionB, ExtensionB],
+ });
+
+ const features = [PluginA, PluginB];
+
+ expect(() =>
+ createAppGraph({ features, config, builtinExtensions: [] }),
+ ).toThrow(
+ "The following extensions are duplicated: The extension 'A' was provided 2 time(s) by the plugin 'A' and 1 time(s) by the plugin 'B', The extension 'B' was provided 2 time(s) by the plugin 'B'",
+ );
+ });
+
+ it('throws an error when duplicated extension overrides are detected', () => {
+ expect(() =>
+ createAppGraph({
+ features: [
+ createExtensionOverrides({
+ extensions: [
+ createExtension({ ...extBase, id: 'a' }),
+ createExtension({ ...extBase, id: 'a' }),
+ createExtension({ ...extBase, id: 'b' }),
+ ],
+ }),
+ createExtensionOverrides({
+ extensions: [createExtension({ ...extBase, id: 'b' })],
+ }),
+ ],
+ config: new MockConfigApi({}),
+ builtinExtensions: [],
+ }),
+ ).toThrow('The following extensions had duplicate overrides: a, b');
+ });
+});
diff --git a/packages/frontend-app-api/src/graph/createAppGraph.ts b/packages/frontend-app-api/src/graph/createAppGraph.ts
new file mode 100644
index 0000000000..ace8595f53
--- /dev/null
+++ b/packages/frontend-app-api/src/graph/createAppGraph.ts
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ BackstagePlugin,
+ Extension,
+ ExtensionOverrides,
+} from '@backstage/frontend-plugin-api';
+import { readAppExtensionsConfig } from './readAppExtensionsConfig';
+import { resolveAppGraph } from './resolveAppGraph';
+import { resolveAppNodeSpecs } from './resolveAppNodeSpecs';
+import { AppGraph } from './types';
+import { Config } from '@backstage/config';
+import { instantiateAppNodeTree } from './instantiateAppNodeTree';
+
+/** @internal */
+export interface CreateAppGraphOptions {
+ features: (BackstagePlugin | ExtensionOverrides)[];
+ builtinExtensions: Extension[];
+ config: Config;
+}
+
+/** @internal */
+export function createAppGraph(options: CreateAppGraphOptions): AppGraph {
+ const appGraph = resolveAppGraph(
+ 'core',
+ resolveAppNodeSpecs({
+ features: options.features,
+ builtinExtensions: options.builtinExtensions,
+ parameters: readAppExtensionsConfig(options.config),
+ forbidden: new Set(['core']),
+ }),
+ );
+ instantiateAppNodeTree(appGraph.root);
+ return appGraph;
+}
diff --git a/packages/frontend-app-api/src/graph/index.ts b/packages/frontend-app-api/src/graph/index.ts
new file mode 100644
index 0000000000..4a3b54dffb
--- /dev/null
+++ b/packages/frontend-app-api/src/graph/index.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export type {
+ AppNode,
+ AppNodeEdges,
+ AppNodeInstance,
+ AppNodeSpec,
+} from './types';
+export { createAppGraph } from './createAppGraph';
diff --git a/packages/frontend-app-api/src/graph/instantiateAppNodeTree.test.ts b/packages/frontend-app-api/src/graph/instantiateAppNodeTree.test.ts
new file mode 100644
index 0000000000..21f364dda2
--- /dev/null
+++ b/packages/frontend-app-api/src/graph/instantiateAppNodeTree.test.ts
@@ -0,0 +1,554 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ Extension,
+ createExtension,
+ createExtensionDataRef,
+ createExtensionInput,
+ createSchemaFromZod,
+} from '@backstage/frontend-plugin-api';
+import {
+ createAppNodeInstance,
+ instantiateAppNodeTree,
+} from './instantiateAppNodeTree';
+import { AppNodeInstance, AppNodeSpec } from './types';
+import { resolveAppGraph } from './resolveAppGraph';
+
+const testDataRef = createExtensionDataRef('test');
+const otherDataRef = createExtensionDataRef('other');
+const inputMirrorDataRef = createExtensionDataRef('mirror');
+
+const simpleExtension = createExtension({
+ id: 'core.test',
+ attachTo: { id: 'ignored', input: 'ignored' },
+ output: {
+ test: testDataRef,
+ other: otherDataRef.optional(),
+ },
+ configSchema: createSchemaFromZod(z =>
+ z.object({
+ output: z.string().default('test'),
+ other: z.number().optional(),
+ }),
+ ),
+ factory({ bind, config }) {
+ bind({ test: config.output, other: config.other });
+ },
+});
+
+function makeSpec(
+ extension: Extension,
+ config?: TConfig,
+): AppNodeSpec {
+ return {
+ id: extension.id,
+ attachTo: extension.attachTo,
+ disabled: extension.disabled,
+ extension,
+ config,
+ source: undefined,
+ };
+}
+
+function makeInstanceWithId(
+ extension: Extension,
+ config?: TConfig,
+): { id: string; instance: AppNodeInstance } {
+ return {
+ id: extension.id,
+ instance: createAppNodeInstance({
+ spec: makeSpec(extension, config),
+ attachments: new Map(),
+ }),
+ };
+}
+
+describe('instantiateAppNodeTree', () => {
+ it('should instantiate a single node', () => {
+ const graph = resolveAppGraph('root-node', [
+ { ...makeSpec(simpleExtension), id: 'root-node' },
+ ]);
+ expect(graph.root.instance).not.toBeDefined();
+ instantiateAppNodeTree(graph.root);
+ expect(graph.root.instance).toBeDefined();
+ expect(graph.root.instance?.getData(testDataRef)).toBe('test');
+
+ // Multiple calls should have no effect
+ instantiateAppNodeTree(graph.root);
+ expect(graph.root.instance).toBeDefined();
+ });
+
+ it('should not instantiate disabled nodes', () => {
+ const graph = resolveAppGraph('root-node', [
+ { ...makeSpec(simpleExtension), id: 'root-node', disabled: true },
+ ]);
+ expect(graph.root.instance).not.toBeDefined();
+ instantiateAppNodeTree(graph.root);
+ expect(graph.root.instance).not.toBeDefined();
+ });
+
+ it('should instantiate a node with attachments', () => {
+ const graph = resolveAppGraph('root-node', [
+ {
+ ...makeSpec(
+ createExtension({
+ id: 'root-node',
+ attachTo: { id: 'ignored', input: 'ignored' },
+ inputs: {
+ test: createExtensionInput({ test: testDataRef }),
+ },
+ output: {
+ inputMirror: inputMirrorDataRef,
+ },
+ factory({ bind, inputs }) {
+ bind({ inputMirror: inputs });
+ },
+ }),
+ ),
+ },
+ {
+ ...makeSpec(simpleExtension),
+ id: 'child-node',
+ attachTo: { id: 'root-node', input: 'test' },
+ },
+ ]);
+
+ const childNode = graph.nodes.get('child-node');
+ expect(childNode).toBeDefined();
+
+ expect(graph.root.instance).not.toBeDefined();
+ expect(childNode?.instance).not.toBeDefined();
+ instantiateAppNodeTree(graph.root);
+ expect(graph.root.instance).toBeDefined();
+ expect(childNode?.instance).toBeDefined();
+ expect(graph.root.instance?.getData(inputMirrorDataRef)).toEqual({
+ test: [{ test: 'test' }],
+ });
+
+ // Multiple calls should have no effect
+ instantiateAppNodeTree(graph.root);
+ expect(graph.root.instance).toBeDefined();
+ expect(childNode?.instance).toBeDefined();
+ });
+
+ it('should not instantiate disabled attachments', () => {
+ const graph = resolveAppGraph('root-node', [
+ {
+ ...makeSpec(
+ createExtension({
+ id: 'root-node',
+ attachTo: { id: 'ignored', input: 'ignored' },
+ inputs: {
+ test: createExtensionInput({ test: testDataRef }),
+ },
+ output: {
+ inputMirror: inputMirrorDataRef,
+ },
+ factory({ bind, inputs }) {
+ bind({ inputMirror: inputs });
+ },
+ }),
+ ),
+ },
+ {
+ ...makeSpec(simpleExtension),
+ id: 'child-node',
+ attachTo: { id: 'root-node', input: 'test' },
+ disabled: true,
+ },
+ ]);
+
+ const childNode = graph.nodes.get('child-node');
+ expect(childNode).toBeDefined();
+
+ expect(graph.root.instance).not.toBeDefined();
+ expect(childNode?.instance).not.toBeDefined();
+ instantiateAppNodeTree(graph.root);
+ expect(graph.root.instance).toBeDefined();
+ expect(childNode?.instance).not.toBeDefined();
+ expect(graph.root.instance?.getData(inputMirrorDataRef)).toEqual({
+ test: [],
+ });
+ });
+});
+
+describe('createAppNodeInstance', () => {
+ it('should create a simple extension instance', () => {
+ const attachments = new Map();
+ const instance = createAppNodeInstance({
+ spec: makeSpec(simpleExtension),
+ attachments,
+ });
+
+ expect(Array.from(instance.getDataRefs())).toEqual([
+ testDataRef,
+ otherDataRef.optional(),
+ ]);
+ expect(instance.getData(testDataRef)).toEqual('test');
+ });
+
+ it('should create an extension with different kind of inputs', () => {
+ const attachments = new Map([
+ [
+ 'optionalSingletonPresent',
+ [
+ makeInstanceWithId(simpleExtension, {
+ output: 'optionalSingletonPresent',
+ }),
+ ],
+ ],
+ [
+ 'singleton',
+ [
+ makeInstanceWithId(simpleExtension, {
+ output: 'singleton',
+ other: 2,
+ }),
+ ],
+ ],
+ [
+ 'many',
+ [
+ makeInstanceWithId(simpleExtension, { output: 'many1' }),
+ makeInstanceWithId(simpleExtension, { output: 'many2', other: 3 }),
+ ],
+ ],
+ ]);
+ const instance = createAppNodeInstance({
+ attachments,
+ spec: makeSpec(
+ createExtension({
+ id: 'core.test',
+ attachTo: { id: 'ignored', input: 'ignored' },
+ inputs: {
+ optionalSingletonPresent: createExtensionInput(
+ {
+ test: testDataRef,
+ other: otherDataRef.optional(),
+ },
+ { singleton: true, optional: true },
+ ),
+ optionalSingletonMissing: createExtensionInput(
+ {
+ test: testDataRef,
+ other: otherDataRef.optional(),
+ },
+ { singleton: true, optional: true },
+ ),
+ singleton: createExtensionInput(
+ {
+ test: testDataRef,
+ other: otherDataRef.optional(),
+ },
+ { singleton: true },
+ ),
+ many: createExtensionInput({
+ test: testDataRef,
+ other: otherDataRef.optional(),
+ }),
+ },
+ output: {
+ inputMirror: inputMirrorDataRef,
+ },
+ factory({ bind, inputs }) {
+ bind({ inputMirror: inputs });
+ },
+ }),
+ ),
+ });
+
+ expect(Array.from(instance.getDataRefs())).toEqual([inputMirrorDataRef]);
+ expect(instance.getData(inputMirrorDataRef)).toEqual({
+ optionalSingletonPresent: { test: 'optionalSingletonPresent' },
+ singleton: { test: 'singleton', other: 2 },
+ many: [{ test: 'many1' }, { test: 'many2', other: 3 }],
+ });
+ });
+
+ it('should refuse to create an extension with invalid config', () => {
+ expect(() =>
+ createAppNodeInstance({
+ spec: {
+ ...makeSpec(simpleExtension),
+ config: { other: 'not-a-number' },
+ },
+ attachments: new Map(),
+ }),
+ ).toThrow(
+ "Invalid configuration for extension 'core.test'; caused by Error: Expected number, received string at 'other'",
+ );
+ });
+
+ it('should forward extension factory errors', () => {
+ expect(() =>
+ createAppNodeInstance({
+ spec: makeSpec(
+ createExtension({
+ id: 'core.test',
+ attachTo: { id: 'ignored', input: 'ignored' },
+ output: {},
+ factory() {
+ const error = new Error('NOPE');
+ error.name = 'NopeError';
+ throw error;
+ },
+ }),
+ ),
+ attachments: new Map(),
+ }),
+ ).toThrow(
+ "Failed to instantiate extension 'core.test'; caused by NopeError: NOPE",
+ );
+ });
+
+ it('should refuse to create an instance with duplicate output', () => {
+ expect(() =>
+ createAppNodeInstance({
+ spec: makeSpec(
+ createExtension({
+ id: 'core.test',
+ attachTo: { id: 'ignored', input: 'ignored' },
+ output: {
+ test1: testDataRef,
+ test2: testDataRef,
+ },
+ factory({ bind }) {
+ bind({ test1: 'test', test2: 'test2' });
+ },
+ }),
+ ),
+ attachments: new Map(),
+ }),
+ ).toThrow(
+ "Failed to instantiate extension 'core.test', duplicate extension data 'test' received via output 'test2'",
+ );
+ });
+
+ it('should refuse to create an instance with disconnected output data', () => {
+ expect(() =>
+ createAppNodeInstance({
+ spec: makeSpec(
+ createExtension({
+ id: 'core.test',
+ attachTo: { id: 'ignored', input: 'ignored' },
+ output: {
+ test: testDataRef,
+ },
+ factory({ bind }) {
+ bind({ nonexistent: 'test' } as any);
+ },
+ }),
+ ),
+ attachments: new Map(),
+ }),
+ ).toThrow(
+ "Failed to instantiate extension 'core.test', unknown output provided via 'nonexistent'",
+ );
+ });
+
+ it('should refuse to create an instance with missing required input', () => {
+ expect(() =>
+ createAppNodeInstance({
+ spec: makeSpec(
+ createExtension({
+ id: 'core.test',
+ attachTo: { id: 'ignored', input: 'ignored' },
+ inputs: {
+ singleton: createExtensionInput(
+ {
+ test: testDataRef,
+ },
+ { singleton: true },
+ ),
+ },
+ output: {},
+ factory() {},
+ }),
+ ),
+ attachments: new Map(),
+ }),
+ ).toThrow(
+ "Failed to instantiate extension 'core.test', input 'singleton' is required but was not received",
+ );
+ });
+
+ it('should refuse to create an instance with undeclared inputs', () => {
+ expect(() =>
+ createAppNodeInstance({
+ attachments: new Map([
+ [
+ 'declared',
+ [
+ makeInstanceWithId(simpleExtension, {
+ output: 'many1',
+ }),
+ ],
+ ],
+ [
+ 'undeclared',
+ [
+ makeInstanceWithId(simpleExtension, {
+ output: 'many1',
+ }),
+ ],
+ ],
+ ]),
+ spec: makeSpec(
+ createExtension({
+ id: 'core.test',
+ attachTo: { id: 'ignored', input: 'ignored' },
+ inputs: {
+ declared: createExtensionInput({
+ test: testDataRef,
+ }),
+ },
+ output: {},
+ factory() {},
+ }),
+ ),
+ }),
+ ).toThrow(
+ "Failed to instantiate extension 'core.test', received undeclared input 'undeclared' from extension 'core.test'",
+ );
+ });
+
+ it('should refuse to create an instance with multiple undeclared inputs', () => {
+ expect(() =>
+ createAppNodeInstance({
+ attachments: new Map([
+ [
+ 'undeclared1',
+ [makeInstanceWithId(simpleExtension, { output: 'many1' })],
+ ],
+ [
+ 'undeclared2',
+ [
+ makeInstanceWithId(simpleExtension, { output: 'many1' }),
+ makeInstanceWithId(simpleExtension, { output: 'many1' }),
+ ],
+ ],
+ ]),
+ spec: makeSpec(
+ createExtension({
+ id: 'core.test',
+ attachTo: { id: 'ignored', input: 'ignored' },
+ output: {},
+ factory() {},
+ }),
+ ),
+ }),
+ ).toThrow(
+ "Failed to instantiate extension 'core.test', received undeclared inputs 'undeclared1' from extension 'core.test' and 'undeclared2' from extensions 'core.test', 'core.test'",
+ );
+ });
+
+ it('should refuse to create an instance with multiple inputs for required singleton', () => {
+ expect(() =>
+ createAppNodeInstance({
+ attachments: new Map([
+ [
+ 'singleton',
+ [
+ makeInstanceWithId(simpleExtension, { output: 'many1' }),
+ makeInstanceWithId(simpleExtension, { output: 'many2' }),
+ ],
+ ],
+ ]),
+ spec: makeSpec(
+ createExtension({
+ id: 'core.test',
+ attachTo: { id: 'ignored', input: 'ignored' },
+ inputs: {
+ singleton: createExtensionInput(
+ {
+ test: testDataRef,
+ },
+ { singleton: true },
+ ),
+ },
+ output: {},
+ factory() {},
+ }),
+ ),
+ }),
+ ).toThrow(
+ "Failed to instantiate extension 'core.test', expected exactly one 'singleton' input but received multiple: 'core.test', 'core.test'",
+ );
+ });
+
+ it('should refuse to create an instance with multiple inputs for optional singleton', () => {
+ expect(() =>
+ createAppNodeInstance({
+ attachments: new Map([
+ [
+ 'singleton',
+ [
+ makeInstanceWithId(simpleExtension, { output: 'many1' }),
+ makeInstanceWithId(simpleExtension, { output: 'many2' }),
+ ],
+ ],
+ ]),
+ spec: makeSpec(
+ createExtension({
+ id: 'core.test',
+ attachTo: { id: 'ignored', input: 'ignored' },
+ inputs: {
+ singleton: createExtensionInput(
+ {
+ test: testDataRef,
+ },
+ { singleton: true, optional: true },
+ ),
+ },
+ output: {},
+ factory() {},
+ }),
+ ),
+ }),
+ ).toThrow(
+ "Failed to instantiate extension 'core.test', expected at most one 'singleton' input but received multiple: 'core.test', 'core.test'",
+ );
+ });
+
+ it('should refuse to create an instance with multiple inputs that did not provide required data', () => {
+ expect(() =>
+ createAppNodeInstance({
+ attachments: new Map([
+ ['singleton', [makeInstanceWithId(simpleExtension, undefined)]],
+ ]),
+ spec: makeSpec(
+ createExtension({
+ id: 'core.test',
+ attachTo: { id: 'ignored', input: 'ignored' },
+ inputs: {
+ singleton: createExtensionInput(
+ {
+ other: otherDataRef,
+ },
+ { singleton: true },
+ ),
+ },
+ output: {},
+ factory() {},
+ }),
+ ),
+ }),
+ ).toThrow(
+ "Failed to instantiate extension 'core.test', input 'singleton' did not receive required extension data 'other' from extension 'core.test'",
+ );
+ });
+});
diff --git a/packages/frontend-app-api/src/wiring/createExtensionInstance.ts b/packages/frontend-app-api/src/graph/instantiateAppNodeTree.ts
similarity index 51%
rename from packages/frontend-app-api/src/wiring/createExtensionInstance.ts
rename to packages/frontend-app-api/src/graph/instantiateAppNodeTree.ts
index 5a325dc0a0..ba0c85692e 100644
--- a/packages/frontend-app-api/src/wiring/createExtensionInstance.ts
+++ b/packages/frontend-app-api/src/graph/instantiateAppNodeTree.ts
@@ -17,36 +17,22 @@
import {
AnyExtensionDataMap,
AnyExtensionInputMap,
- BackstagePlugin,
- Extension,
ExtensionDataRef,
} from '@backstage/frontend-plugin-api';
import mapValues from 'lodash/mapValues';
+import { AppNode, AppNodeInstance, AppNodeSpec } from './types';
-/** @internal */
-export interface ExtensionInstance {
- readonly $$type: '@backstage/ExtensionInstance';
-
- readonly id: string;
- /**
- * Get concrete value for the given extension data reference. Returns undefined if no value is available.
- */
- getData(ref: ExtensionDataRef): T | undefined;
- /**
- * Maps input names to the actual instances given to them.
- */
- readonly attachments: Map;
-
- readonly source?: BackstagePlugin;
-}
+type Mutable = {
+ -readonly [P in keyof T]: T[P];
+};
function resolveInputData(
dataMap: AnyExtensionDataMap,
- attachment: ExtensionInstance,
+ attachment: { id: string; instance: AppNodeInstance },
inputName: string,
) {
return mapValues(dataMap, ref => {
- const value = attachment.getData(ref);
+ const value = attachment.instance.getData(ref);
if (value === undefined && !ref.config.optional) {
throw new Error(
`input '${inputName}' did not receive required extension data '${ref.id}' from extension '${attachment.id}'`,
@@ -58,11 +44,12 @@ function resolveInputData(
function resolveInputs(
inputMap: AnyExtensionInputMap,
- attachments: Map,
+ attachments: ReadonlyMap,
) {
const undeclaredAttachments = Array.from(attachments.entries()).filter(
([inputName]) => inputMap[inputName] === undefined,
);
+ // TODO: Make this a warning rather than an error
if (undeclaredAttachments.length > 0) {
throw new Error(
`received undeclared input${
@@ -79,113 +66,49 @@ function resolveInputs(
}
return mapValues(inputMap, (input, inputName) => {
- const attachedInstances = attachments.get(inputName) ?? [];
+ const attachedNodes = attachments.get(inputName) ?? [];
+
if (input.config.singleton) {
- if (attachedInstances.length > 1) {
+ if (attachedNodes.length > 1) {
+ const attachedNodeIds = attachedNodes.map(e => e.id);
throw Error(
`expected ${
input.config.optional ? 'at most' : 'exactly'
- } one '${inputName}' input but received multiple: '${attachedInstances
- .map(e => e.id)
- .join("', '")}'`,
+ } one '${inputName}' input but received multiple: '${attachedNodeIds.join(
+ "', '",
+ )}'`,
);
- } else if (attachedInstances.length === 0) {
+ } else if (attachedNodes.length === 0) {
if (input.config.optional) {
return undefined;
}
throw Error(`input '${inputName}' is required but was not received`);
}
- return resolveInputData(
- input.extensionData,
- attachedInstances[0],
- inputName,
- );
+ return resolveInputData(input.extensionData, attachedNodes[0], inputName);
}
- return attachedInstances.map(attachment =>
+ return attachedNodes.map(attachment =>
resolveInputData(input.extensionData, attachment, inputName),
);
});
}
-function indent(str: string) {
- return str.replace(/^/gm, ' ');
-}
-
-class ExtensionInstanceImpl implements ExtensionInstance {
- readonly $$type = '@backstage/ExtensionInstance';
-
- readonly id: string;
- readonly #extensionData: Map;
- readonly attachments: Map;
- readonly source?: BackstagePlugin;
-
- constructor(
- id: string,
- extensionData: Map,
- attachments: Map,
- source: BackstagePlugin | undefined,
- ) {
- this.id = id;
- this.#extensionData = extensionData;
- this.attachments = attachments;
- this.source = source;
- }
-
- getData(ref: ExtensionDataRef): T | undefined {
- return this.#extensionData.get(ref.id) as T | undefined;
- }
-
- toJSON() {
- return {
- id: this.id,
- output:
- this.#extensionData.size > 0
- ? [...this.#extensionData.keys()]
- : undefined,
- attachments:
- this.attachments.size > 0
- ? Object.fromEntries(this.attachments)
- : undefined,
- };
- }
-
- toString() {
- const out =
- this.#extensionData.size > 0
- ? ` out=[${[...this.#extensionData.keys()].join(', ')}]`
- : '';
-
- if (this.attachments.size === 0) {
- return `<${this.id}${out} />`;
- }
-
- return [
- `<${this.id}${out}>`,
- ...[...this.attachments.entries()].map(([k, v]) =>
- indent([`${k} [`, ...v.map(e => indent(e.toString())), `]`].join('\n')),
- ),
- `${this.id}>`,
- ].join('\n');
- }
-}
-
/** @internal */
-export function createExtensionInstance(options: {
- extension: Extension;
- config: unknown;
- source?: BackstagePlugin;
- attachments: Map;
-}): ExtensionInstance {
- const { extension, config, source, attachments } = options;
+export function createAppNodeInstance(options: {
+ spec: AppNodeSpec;
+ attachments: ReadonlyMap;
+}): AppNodeInstance {
+ const { spec, attachments } = options;
+ const { id, extension, config, source } = spec;
const extensionData = new Map();
+ const extensionDataRefs = new Set>();
let parsedConfig: unknown;
try {
parsedConfig = extension.configSchema?.parse(config ?? {});
} catch (e) {
throw new Error(
- `Invalid configuration for extension '${extension.id}'; caused by ${e}`,
+ `Invalid configuration for extension '${id}'; caused by ${e}`,
);
}
@@ -205,22 +128,65 @@ export function createExtensionInstance(options: {
);
}
extensionData.set(ref.id, output);
+ extensionDataRefs.add(ref);
}
},
inputs: resolveInputs(extension.inputs, attachments),
});
} catch (e) {
throw new Error(
- `Failed to instantiate extension '${extension.id}'${
+ `Failed to instantiate extension '${id}'${
e.name === 'Error' ? `, ${e.message}` : `; caused by ${e}`
}`,
);
}
- return new ExtensionInstanceImpl(
- options.extension.id,
- extensionData,
- attachments,
- source,
- );
+ return {
+ getDataRefs() {
+ return extensionDataRefs.values();
+ },
+ getData(ref: ExtensionDataRef): T | undefined {
+ return extensionData.get(ref.id) as T | undefined;
+ },
+ };
+}
+
+/**
+ * Starting at the provided node, instantiate all reachable nodes in the graph that have not been disabled.
+ * @internal
+ */
+export function instantiateAppNodeTree(rootNode: AppNode): void {
+ function createInstance(node: AppNode): AppNodeInstance | undefined {
+ if (node.instance) {
+ return node.instance;
+ }
+ if (node.spec.disabled) {
+ return undefined;
+ }
+
+ const instantiatedAttachments = new Map<
+ string,
+ { id: string; instance: AppNodeInstance }[]
+ >();
+
+ for (const [input, children] of node.edges.attachments) {
+ const instantiatedChildren = children.flatMap(child => {
+ const childInstance = createInstance(child);
+ if (!childInstance) {
+ return [];
+ }
+ return [{ id: child.spec.id, instance: childInstance }];
+ });
+ instantiatedAttachments.set(input, instantiatedChildren);
+ }
+
+ (node as Mutable).instance = createAppNodeInstance({
+ spec: node.spec,
+ attachments: instantiatedAttachments,
+ });
+
+ return node.instance;
+ }
+
+ createInstance(rootNode);
}
diff --git a/packages/frontend-app-api/src/wiring/parameters.test.ts b/packages/frontend-app-api/src/graph/readAppExtensionsConfig.test.ts
similarity index 59%
rename from packages/frontend-app-api/src/wiring/parameters.test.ts
rename to packages/frontend-app-api/src/graph/readAppExtensionsConfig.test.ts
index 4ee420f667..91f6992f29 100644
--- a/packages/frontend-app-api/src/wiring/parameters.test.ts
+++ b/packages/frontend-app-api/src/graph/readAppExtensionsConfig.test.ts
@@ -15,204 +15,16 @@
*/
import { ConfigReader } from '@backstage/config';
-import {
- createExtensionOverrides,
- createPlugin,
- Extension,
-} from '@backstage/frontend-plugin-api';
import { JsonValue } from '@backstage/types';
import {
expandShorthandExtensionParameters,
- mergeExtensionParameters,
- readAppExtensionParameters,
-} from './parameters';
+ readAppExtensionsConfig,
+} from './readAppExtensionsConfig';
-function makeExt(
- id: string,
- status: 'disabled' | 'enabled' = 'enabled',
- attachId: string = 'root',
-) {
- return {
- id,
- attachTo: { id: attachId, input: 'default' },
- disabled: status === 'disabled',
- } as Extension;
-}
-
-describe('mergeExtensionParameters', () => {
- it('should filter out disabled extension instances', () => {
- expect(
- mergeExtensionParameters({
- features: [],
- builtinExtensions: [makeExt('a', 'disabled')],
- parameters: [],
- }),
- ).toEqual([]);
- });
-
- it('should pass through extension instances', () => {
- const a = makeExt('a');
- const b = makeExt('b');
- expect(
- mergeExtensionParameters({
- features: [],
- builtinExtensions: [a, b],
- parameters: [],
- }),
- ).toEqual([
- { extension: a, attachTo: { id: 'root', input: 'default' } },
- { extension: b, attachTo: { id: 'root', input: 'default' } },
- ]);
- });
-
- it('should override attachment points', () => {
- const a = makeExt('a');
- const b = makeExt('b');
- const pluginA = createPlugin({ id: 'test', extensions: [a] });
- expect(
- mergeExtensionParameters({
- features: [pluginA],
- builtinExtensions: [b],
- parameters: [
- {
- id: 'b',
- attachTo: { id: 'derp', input: 'default' },
- },
- ],
- }),
- ).toEqual([
- {
- extension: a,
- attachTo: { id: 'root', input: 'default' },
- source: pluginA,
- },
- { extension: b, attachTo: { id: 'derp', input: 'default' } },
- ]);
- });
-
- it('should fully override configuration and duplicate', () => {
- const a = makeExt('a');
- const b = makeExt('b');
- const plugin = createPlugin({ id: 'test', extensions: [a, b] });
- expect(
- mergeExtensionParameters({
- features: [plugin],
- builtinExtensions: [],
- parameters: [
- {
- id: 'a',
- config: { foo: { bar: 1 } },
- },
- {
- id: 'b',
- config: { foo: { bar: 2 } },
- },
- {
- id: 'b',
- config: { foo: { qux: 3 } },
- },
- ],
- }),
- ).toEqual([
- {
- extension: a,
- attachTo: { id: 'root', input: 'default' },
- source: plugin,
- config: { foo: { bar: 1 } },
- },
- {
- extension: b,
- attachTo: { id: 'root', input: 'default' },
- source: plugin,
- config: { foo: { qux: 3 } },
- },
- ]);
- });
-
- it('should place enabled instances in the order that they were enabled', () => {
- const a = makeExt('a', 'disabled');
- const b = makeExt('b', 'disabled');
- expect(
- mergeExtensionParameters({
- features: [createPlugin({ id: 'empty', extensions: [] })],
- builtinExtensions: [a, b],
- parameters: [
- {
- id: 'b',
- disabled: false,
- },
- {
- id: 'a',
- disabled: false,
- },
- ],
- }),
- ).toEqual([
- { extension: b, attachTo: { id: 'root', input: 'default' } },
- { extension: a, attachTo: { id: 'root', input: 'default' } },
- ]);
- });
-
- it('should apply extension overrides', () => {
- const a = makeExt('a');
- const b = makeExt('b');
- const plugin = createPlugin({ id: 'test', extensions: [a, b] });
- const aOverride = makeExt('a', 'enabled', 'other');
- const bOverride = makeExt('b', 'disabled', 'other');
- const cOverride = makeExt('c');
-
- const result = mergeExtensionParameters({
- features: [
- plugin,
- createExtensionOverrides({
- extensions: [aOverride, bOverride, cOverride],
- }),
- ],
- builtinExtensions: [],
- parameters: [],
- });
-
- expect(result.length).toBe(2);
- expect(result[0].extension).toBe(aOverride);
- expect(result[0].attachTo).toEqual({ id: 'other', input: 'default' });
- expect(result[0].config).toEqual(undefined);
- expect(result[0].source).toBe(plugin);
-
- expect(result[1]).toEqual({
- extension: cOverride,
- attachTo: { id: 'root', input: 'default' },
- config: undefined,
- source: undefined,
- });
- });
-
- it('should use order from configuration when rather than overrides', () => {
- const a = makeExt('a', 'disabled');
- const b = makeExt('b', 'disabled');
- const c = makeExt('c', 'disabled');
- const aOverride = makeExt('c', 'disabled');
- const bOverride = makeExt('b', 'disabled');
- const cOverride = makeExt('a', 'disabled');
-
- const result = mergeExtensionParameters({
- features: [
- createPlugin({ id: 'test', extensions: [a, b, c] }),
- createExtensionOverrides({
- extensions: [cOverride, bOverride, aOverride],
- }),
- ],
- builtinExtensions: [],
- parameters: ['b', 'c', 'a'].map(id => ({ id, disabled: false })),
- });
-
- expect(result.map(r => r.extension.id)).toEqual(['b', 'c', 'a']);
- });
-});
-
-describe('readAppExtensionParameters', () => {
+describe('readAppExtensionsConfig', () => {
it('should disable extension with shorthand notation', () => {
expect(
- readAppExtensionParameters(
+ readAppExtensionsConfig(
new ConfigReader({ app: { extensions: [{ 'core.router': false }] } }),
),
).toEqual([
@@ -222,7 +34,7 @@ describe('readAppExtensionParameters', () => {
},
]);
expect(
- readAppExtensionParameters(
+ readAppExtensionsConfig(
new ConfigReader({
app: { extensions: [{ 'core.router': { disabled: true } }] },
}),
@@ -239,7 +51,7 @@ describe('readAppExtensionParameters', () => {
it('should enable extension with shorthand notation', () => {
expect(
- readAppExtensionParameters(
+ readAppExtensionsConfig(
new ConfigReader({ app: { extensions: ['core.router'] } }),
),
).toEqual([
@@ -249,7 +61,7 @@ describe('readAppExtensionParameters', () => {
},
]);
expect(
- readAppExtensionParameters(
+ readAppExtensionsConfig(
new ConfigReader({ app: { extensions: [{ 'core.router': true }] } }),
),
).toEqual([
@@ -259,7 +71,7 @@ describe('readAppExtensionParameters', () => {
},
]);
expect(
- readAppExtensionParameters(
+ readAppExtensionsConfig(
new ConfigReader({
app: { extensions: [{ 'core.router': { disabled: false } }] },
}),
@@ -274,7 +86,7 @@ describe('readAppExtensionParameters', () => {
it('should not allow string keys', () => {
expect(() =>
- readAppExtensionParameters(
+ readAppExtensionsConfig(
new ConfigReader({
app: {
extensions: [{ 'core.router': 'some-string' }],
@@ -288,7 +100,7 @@ describe('readAppExtensionParameters', () => {
it('should not allow invalid keys', () => {
expect(() =>
- readAppExtensionParameters(
+ readAppExtensionsConfig(
new ConfigReader({
app: {
extensions: [
diff --git a/packages/frontend-app-api/src/graph/readAppExtensionsConfig.ts b/packages/frontend-app-api/src/graph/readAppExtensionsConfig.ts
new file mode 100644
index 0000000000..2b5b51ce92
--- /dev/null
+++ b/packages/frontend-app-api/src/graph/readAppExtensionsConfig.ts
@@ -0,0 +1,199 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Config } from '@backstage/config';
+import { JsonValue } from '@backstage/types';
+
+export interface ExtensionParameters {
+ id: string;
+ attachTo?: { id: string; input: string };
+ disabled?: boolean;
+ config?: unknown;
+}
+
+const knownExtensionParameters = ['attachTo', 'disabled', 'config'];
+
+// Since we'll never merge arrays in config the config reader context
+// isn't too much of a help. Fall back to manual config reading logic
+// as the Config interface makes it quite hard for us otherwise.
+/** @internal */
+export function readAppExtensionsConfig(
+ rootConfig: Config,
+): ExtensionParameters[] {
+ const arr = rootConfig.getOptional('app.extensions');
+ if (!Array.isArray(arr)) {
+ if (arr === undefined) {
+ return [];
+ }
+ // This will throw, and show which part of config had the wrong type
+ rootConfig.getConfigArray('app.extensions');
+ return [];
+ }
+
+ return arr.map((arrayEntry, arrayIndex) =>
+ expandShorthandExtensionParameters(arrayEntry, arrayIndex),
+ );
+}
+
+/** @internal */
+export function expandShorthandExtensionParameters(
+ arrayEntry: JsonValue,
+ arrayIndex: number,
+): ExtensionParameters {
+ function errorMsg(msg: string, key?: string, prop?: string) {
+ return `Invalid extension configuration at app.extensions[${arrayIndex}]${
+ key ? `[${key}]` : ''
+ }${prop ? `.${prop}` : ''}, ${msg}`;
+ }
+
+ // NOTE(freben): This check is intentionally not complete and doesn't check
+ // whether letters and digits are used, etc. It's not up to the config reading
+ // logic to decide what constitutes a valid extension ID; that should be
+ // decided by the logic that loads and instantiates the extensions. This check
+ // is just here to catch real mistakes or truly conceptually wrong input.
+ function assertValidId(id: string) {
+ if (!id || id !== id.trim()) {
+ throw new Error(
+ errorMsg('extension ID must not be empty or contain whitespace'),
+ );
+ }
+
+ if (id.includes('/')) {
+ let message = `extension ID must not contain slashes; got '${id}'`;
+ const good = id.split('/')[0];
+ if (good) {
+ message += `, did you mean '${good}'?`;
+ }
+ throw new Error(errorMsg(message));
+ }
+ }
+
+ // Example YAML:
+ // - entity.card.about
+ if (typeof arrayEntry === 'string') {
+ assertValidId(arrayEntry);
+ return {
+ id: arrayEntry,
+ disabled: false,
+ };
+ }
+
+ // All remaining cases are single-key objects
+ if (
+ typeof arrayEntry !== 'object' ||
+ arrayEntry === null ||
+ Array.isArray(arrayEntry)
+ ) {
+ throw new Error(errorMsg('must be a string or an object'));
+ }
+ const keys = Object.keys(arrayEntry);
+ if (keys.length !== 1) {
+ const joinedKeys = keys.length ? `'${keys.join("', '")}'` : 'none';
+ throw new Error(errorMsg(`must have exactly one key, got ${joinedKeys}`));
+ }
+
+ const id = String(keys[0]);
+ const value = arrayEntry[id];
+ assertValidId(id);
+
+ // This example covers a potentially common mistake in the syntax
+ // Example YAML:
+ // - entity.card.about:
+ if (value === null) {
+ return {
+ id,
+ disabled: false,
+ };
+ }
+
+ // Example YAML:
+ // - catalog.page.cicd: false
+ if (typeof value === 'boolean') {
+ return {
+ id,
+ disabled: !value,
+ };
+ }
+
+ // The remaining case is the generic object. Example YAML:
+ // - tech-radar.page:
+ // at: core.router/routes
+ // disabled: false
+ // config:
+ // path: /tech-radar
+ // width: 1500
+ // height: 800
+ if (typeof value !== 'object' || Array.isArray(value)) {
+ // We don't mention null here - we don't want people to explicitly enter
+ // - entity.card.about: null
+ throw new Error(errorMsg('value must be a boolean or object', id));
+ }
+
+ const attachTo = value.attachTo as { id: string; input: string } | undefined;
+ const disabled = value.disabled;
+ const config = value.config;
+
+ if (attachTo !== undefined) {
+ if (
+ attachTo === null ||
+ typeof attachTo !== 'object' ||
+ Array.isArray(attachTo)
+ ) {
+ throw new Error(errorMsg('must be an object', id, 'attachTo'));
+ }
+ if (typeof attachTo.id !== 'string' || attachTo.id === '') {
+ throw new Error(
+ errorMsg('must be a non-empty string', id, 'attachTo.id'),
+ );
+ }
+ if (typeof attachTo.input !== 'string' || attachTo.input === '') {
+ throw new Error(
+ errorMsg('must be a non-empty string', id, 'attachTo.input'),
+ );
+ }
+ }
+ if (disabled !== undefined && typeof disabled !== 'boolean') {
+ throw new Error(errorMsg('must be a boolean', id, 'disabled'));
+ }
+ if (
+ config !== undefined &&
+ (typeof config !== 'object' || config === null || Array.isArray(config))
+ ) {
+ throw new Error(errorMsg('must be an object', id, 'config'));
+ }
+
+ const unknownKeys = Object.keys(value).filter(
+ k => !knownExtensionParameters.includes(k),
+ );
+ if (unknownKeys.length > 0) {
+ throw new Error(
+ errorMsg(
+ `unknown parameter; expected one of '${knownExtensionParameters.join(
+ "', '",
+ )}'`,
+ id,
+ unknownKeys.join(', '),
+ ),
+ );
+ }
+
+ return {
+ id,
+ attachTo,
+ disabled,
+ config,
+ };
+}
diff --git a/packages/frontend-app-api/src/graph/resolveAppGraph.test.ts b/packages/frontend-app-api/src/graph/resolveAppGraph.test.ts
new file mode 100644
index 0000000000..2668c51863
--- /dev/null
+++ b/packages/frontend-app-api/src/graph/resolveAppGraph.test.ts
@@ -0,0 +1,166 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createExtension } from '@backstage/frontend-plugin-api';
+import { resolveAppGraph } from './resolveAppGraph';
+
+const extBaseConfig = {
+ id: 'test',
+ attachTo: { id: 'nonexistent', input: 'nonexistent' },
+ output: {},
+ factory() {},
+};
+
+const extension = createExtension(extBaseConfig);
+
+const baseSpec = {
+ extension,
+ attachTo: { id: 'nonexistent', input: 'nonexistent' },
+ disabled: false,
+};
+
+describe('buildAppGraph', () => {
+ it('should fail to create an empty graph', () => {
+ expect(() => resolveAppGraph('core', [])).toThrow(
+ "No root node with id 'core' found in app graph",
+ );
+ });
+
+ it('should create a graph with only one node', () => {
+ const graph = resolveAppGraph('core', [{ ...baseSpec, id: 'core' }]);
+ expect(graph.root).toEqual({
+ spec: { ...baseSpec, id: 'core' },
+ edges: { attachments: new Map() },
+ });
+ expect(Array.from(graph.orphans)).toEqual([]);
+ expect(Array.from(graph.nodes.keys())).toEqual(['core']);
+ });
+
+ it('should create a graph', () => {
+ const graph = resolveAppGraph('b', [
+ { ...baseSpec, id: 'a' },
+ { ...baseSpec, id: 'b' },
+ { ...baseSpec, id: 'c' },
+ { ...baseSpec, attachTo: { id: 'b', input: 'x' }, id: 'bx1' },
+ { ...baseSpec, attachTo: { id: 'b', input: 'x' }, id: 'bx2' },
+ { ...baseSpec, attachTo: { id: 'b', input: 'y' }, id: 'by1' },
+ { ...baseSpec, attachTo: { id: 'd', input: 'x' }, id: 'dx1' },
+ ]);
+
+ expect(Array.from(graph.nodes.keys())).toEqual([
+ 'a',
+ 'b',
+ 'c',
+ 'bx1',
+ 'bx2',
+ 'by1',
+ 'dx1',
+ ]);
+
+ expect(JSON.parse(JSON.stringify(graph.root))).toMatchInlineSnapshot(`
+ {
+ "attachments": {
+ "x": [
+ {
+ "id": "bx1",
+ },
+ {
+ "id": "bx2",
+ },
+ ],
+ "y": [
+ {
+ "id": "by1",
+ },
+ ],
+ },
+ "id": "b",
+ }
+ `);
+ expect(String(graph.root)).toMatchInlineSnapshot(`
+ "
+ x [
+
+
+ ]
+ y [
+
+ ]
+ "
+ `);
+
+ const orphans = Array.from(graph.orphans).map(String);
+ expect(orphans).toMatchInlineSnapshot(`
+ [
+ "",
+ "",
+ "",
+ ]
+ `);
+ });
+
+ it('should create a graph out of order', () => {
+ const graph = resolveAppGraph('b', [
+ { ...baseSpec, attachTo: { id: 'b', input: 'x' }, id: 'bx2' },
+ { ...baseSpec, id: 'a' },
+ { ...baseSpec, attachTo: { id: 'b', input: 'y' }, id: 'by1' },
+ { ...baseSpec, id: 'b' },
+ { ...baseSpec, attachTo: { id: 'b', input: 'x' }, id: 'bx1' },
+ { ...baseSpec, id: 'c' },
+ { ...baseSpec, attachTo: { id: 'd', input: 'x' }, id: 'dx1' },
+ ]);
+
+ expect(Array.from(graph.nodes.keys())).toEqual([
+ 'bx2',
+ 'a',
+ 'by1',
+ 'b',
+ 'bx1',
+ 'c',
+ 'dx1',
+ ]);
+
+ expect(String(graph.root)).toMatchInlineSnapshot(`
+ "
+ x [
+
+
+ ]
+ y [
+
+ ]
+ "
+ `);
+
+ const orphans = Array.from(graph.orphans).map(String);
+ expect(orphans).toMatchInlineSnapshot(`
+ [
+ "",
+ "",
+ "",
+ ]
+ `);
+ });
+
+ it('throws an error when duplicated extensions are detected', () => {
+ expect(() =>
+ resolveAppGraph('core', [
+ { ...baseSpec, id: 'a' },
+ { ...baseSpec, id: 'a' },
+ ]),
+ ).toThrow("Unexpected duplicate extension id 'a'");
+ });
+});
diff --git a/packages/frontend-app-api/src/graph/resolveAppGraph.ts b/packages/frontend-app-api/src/graph/resolveAppGraph.ts
new file mode 100644
index 0000000000..354b4c53ee
--- /dev/null
+++ b/packages/frontend-app-api/src/graph/resolveAppGraph.ts
@@ -0,0 +1,152 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { AppGraph, AppNode, AppNodeInstance, AppNodeSpec } from './types';
+
+function indent(str: string) {
+ return str.replace(/^/gm, ' ');
+}
+
+/** @internal */
+class SerializableAppNode implements AppNode {
+ public readonly spec: AppNodeSpec;
+ public readonly edges = {
+ attachedTo: undefined as { node: AppNode; input: string } | undefined,
+ attachments: new Map(),
+ };
+ public readonly instance?: AppNodeInstance;
+
+ constructor(spec: AppNodeSpec) {
+ this.spec = spec;
+ }
+
+ setParent(parent: SerializableAppNode) {
+ const input = this.spec.attachTo.input;
+
+ this.edges.attachedTo = { node: parent, input };
+
+ const parentInputEdges = parent.edges.attachments.get(input);
+ if (parentInputEdges) {
+ parentInputEdges.push(this);
+ } else {
+ parent.edges.attachments.set(input, [this]);
+ }
+ }
+
+ toJSON() {
+ const dataRefs = this.instance && [...this.instance.getDataRefs()];
+ return {
+ id: this.spec.id,
+ output:
+ dataRefs && dataRefs.length > 0
+ ? dataRefs.map(ref => ref.id)
+ : undefined,
+ attachments:
+ this.edges.attachments.size > 0
+ ? Object.fromEntries(this.edges.attachments)
+ : undefined,
+ };
+ }
+
+ toString(): string {
+ const dataRefs = this.instance && [...this.instance.getDataRefs()];
+ const out =
+ dataRefs && dataRefs.length > 0
+ ? ` out=[${[...dataRefs.keys()].join(', ')}]`
+ : '';
+
+ if (this.edges.attachments.size === 0) {
+ return `<${this.spec.id}${out} />`;
+ }
+
+ return [
+ `<${this.spec.id}${out}>`,
+ ...[...this.edges.attachments.entries()].map(([k, v]) =>
+ indent([`${k} [`, ...v.map(e => indent(e.toString())), `]`].join('\n')),
+ ),
+ `${this.spec.id}>`,
+ ].join('\n');
+ }
+}
+
+/**
+ * Build the app graph by iterating through all node specs and constructing the app
+ * tree with all attachments in the same order as they appear in the input specs array.
+ * @internal
+ */
+export function resolveAppGraph(
+ rootNodeId: string,
+ specs: AppNodeSpec[],
+): AppGraph {
+ const nodes = new Map();
+
+ // A node with the provided rootNodeId must be found in the graph, and it must not be attached to anything
+ let rootNode: AppNode | undefined = undefined;
+
+ // While iterating through the inputs specs we keep track of all nodes that were created
+ // before their parent, and attach them later when the parent is created.
+ // As we find the parents and attach the children, we remove them from this map. This means
+ // that after iterating through all input specs, this will be a map for each root node.
+ const orphansByParent = new Map<
+ string /* parentId */,
+ SerializableAppNode[]
+ >();
+
+ for (const spec of specs) {
+ // The main check with a more helpful error message happens in resolveAppNodeSpecs
+ if (nodes.has(spec.id)) {
+ throw new Error(`Unexpected duplicate extension id '${spec.id}'`);
+ }
+
+ const node = new SerializableAppNode(spec);
+ nodes.set(spec.id, node);
+
+ // TODO: For now we simply ignore the attachTo spec of the root node, but it'd be cleaner if we could avoid defining it
+ if (spec.id === rootNodeId) {
+ rootNode = node;
+ } else {
+ const parent = nodes.get(spec.attachTo.id);
+ if (parent) {
+ node.setParent(parent);
+ } else {
+ const orphanNodesForParent = orphansByParent.get(spec.attachTo.id);
+ if (orphanNodesForParent) {
+ orphanNodesForParent.push(node);
+ } else {
+ orphansByParent.set(spec.attachTo.id, [node]);
+ }
+ }
+ }
+
+ const orphanedChildren = orphansByParent.get(spec.id);
+ if (orphanedChildren) {
+ orphansByParent.delete(spec.id);
+ for (const orphan of orphanedChildren) {
+ orphan.setParent(node);
+ }
+ }
+ }
+
+ if (!rootNode) {
+ throw new Error(`No root node with id '${rootNodeId}' found in app graph`);
+ }
+
+ return {
+ root: rootNode,
+ nodes,
+ orphans: Array.from(orphansByParent.values()).flat(),
+ };
+}
diff --git a/packages/frontend-app-api/src/graph/resolveAppNodeSpecs.test.ts b/packages/frontend-app-api/src/graph/resolveAppNodeSpecs.test.ts
new file mode 100644
index 0000000000..59930968c6
--- /dev/null
+++ b/packages/frontend-app-api/src/graph/resolveAppNodeSpecs.test.ts
@@ -0,0 +1,278 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ createExtensionOverrides,
+ createPlugin,
+ Extension,
+} from '@backstage/frontend-plugin-api';
+import { resolveAppNodeSpecs } from './resolveAppNodeSpecs';
+
+function makeExt(
+ id: string,
+ status: 'disabled' | 'enabled' = 'enabled',
+ attachId: string = 'root',
+) {
+ return {
+ id,
+ attachTo: { id: attachId, input: 'default' },
+ disabled: status === 'disabled',
+ } as Extension;
+}
+
+describe('resolveAppNodeSpecs', () => {
+ it('should filter out disabled extension instances', () => {
+ expect(
+ resolveAppNodeSpecs({
+ features: [],
+ builtinExtensions: [makeExt('a', 'disabled')],
+ parameters: [],
+ }),
+ ).toEqual([]);
+ });
+
+ it('should pass through extension instances', () => {
+ const a = makeExt('a');
+ const b = makeExt('b');
+ expect(
+ resolveAppNodeSpecs({
+ features: [],
+ builtinExtensions: [a, b],
+ parameters: [],
+ }),
+ ).toEqual([
+ {
+ id: 'a',
+ extension: a,
+ attachTo: { id: 'root', input: 'default' },
+ disabled: false,
+ },
+ {
+ id: 'b',
+ extension: b,
+ attachTo: { id: 'root', input: 'default' },
+ disabled: false,
+ },
+ ]);
+ });
+
+ it('should override attachment points', () => {
+ const a = makeExt('a');
+ const b = makeExt('b');
+ const pluginA = createPlugin({ id: 'test', extensions: [a] });
+ expect(
+ resolveAppNodeSpecs({
+ features: [pluginA],
+ builtinExtensions: [b],
+ parameters: [
+ {
+ id: 'b',
+ attachTo: { id: 'derp', input: 'default' },
+ },
+ ],
+ }),
+ ).toEqual([
+ {
+ id: 'a',
+ extension: a,
+ attachTo: { id: 'root', input: 'default' },
+ source: pluginA,
+ disabled: false,
+ },
+ {
+ id: 'b',
+ extension: b,
+ attachTo: { id: 'derp', input: 'default' },
+ disabled: false,
+ },
+ ]);
+ });
+
+ it('should fully override configuration and duplicate', () => {
+ const a = makeExt('a');
+ const b = makeExt('b');
+ const plugin = createPlugin({ id: 'test', extensions: [a, b] });
+ expect(
+ resolveAppNodeSpecs({
+ features: [plugin],
+ builtinExtensions: [],
+ parameters: [
+ {
+ id: 'a',
+ config: { foo: { bar: 1 } },
+ },
+ {
+ id: 'b',
+ config: { foo: { bar: 2 } },
+ },
+ {
+ id: 'b',
+ config: { foo: { qux: 3 } },
+ },
+ ],
+ }),
+ ).toEqual([
+ {
+ id: 'a',
+ extension: a,
+ attachTo: { id: 'root', input: 'default' },
+ source: plugin,
+ config: { foo: { bar: 1 } },
+ disabled: false,
+ },
+ {
+ id: 'b',
+ extension: b,
+ attachTo: { id: 'root', input: 'default' },
+ source: plugin,
+ config: { foo: { qux: 3 } },
+ disabled: false,
+ },
+ ]);
+ });
+
+ it('should place enabled instances in the order that they were enabled', () => {
+ const a = makeExt('a', 'disabled');
+ const b = makeExt('b', 'disabled');
+ expect(
+ resolveAppNodeSpecs({
+ features: [createPlugin({ id: 'empty', extensions: [] })],
+ builtinExtensions: [a, b],
+ parameters: [
+ {
+ id: 'b',
+ disabled: false,
+ },
+ {
+ id: 'a',
+ disabled: false,
+ },
+ ],
+ }),
+ ).toEqual([
+ {
+ id: 'b',
+ extension: b,
+ attachTo: { id: 'root', input: 'default' },
+ disabled: false,
+ },
+ {
+ id: 'a',
+ extension: a,
+ attachTo: { id: 'root', input: 'default' },
+ disabled: false,
+ },
+ ]);
+ });
+
+ it('should apply extension overrides', () => {
+ const a = makeExt('a');
+ const b = makeExt('b');
+ const plugin = createPlugin({ id: 'test', extensions: [a, b] });
+ const aOverride = makeExt('a', 'enabled', 'other');
+ const bOverride = makeExt('b', 'disabled', 'other');
+ const cOverride = makeExt('c');
+
+ const result = resolveAppNodeSpecs({
+ features: [
+ plugin,
+ createExtensionOverrides({
+ extensions: [aOverride, bOverride, cOverride],
+ }),
+ ],
+ builtinExtensions: [],
+ parameters: [],
+ });
+
+ expect(result.length).toBe(2);
+ expect(result[0].extension).toBe(aOverride);
+ expect(result[0].attachTo).toEqual({ id: 'other', input: 'default' });
+ expect(result[0].config).toEqual(undefined);
+ expect(result[0].source).toBe(plugin);
+
+ expect(result[1]).toEqual({
+ id: 'c',
+ extension: cOverride,
+ attachTo: { id: 'root', input: 'default' },
+ config: undefined,
+ source: undefined,
+ disabled: false,
+ });
+ });
+
+ it('should use order from configuration when rather than overrides', () => {
+ const a = makeExt('a', 'disabled');
+ const b = makeExt('b', 'disabled');
+ const c = makeExt('c', 'disabled');
+ const aOverride = makeExt('c', 'disabled');
+ const bOverride = makeExt('b', 'disabled');
+ const cOverride = makeExt('a', 'disabled');
+
+ const result = resolveAppNodeSpecs({
+ features: [
+ createPlugin({ id: 'test', extensions: [a, b, c] }),
+ createExtensionOverrides({
+ extensions: [cOverride, bOverride, aOverride],
+ }),
+ ],
+ builtinExtensions: [],
+ parameters: ['b', 'c', 'a'].map(id => ({ id, disabled: false })),
+ });
+
+ expect(result.map(r => r.extension.id)).toEqual(['b', 'c', 'a']);
+ });
+
+ it('throws an error when a forbidden extension is overridden by a plugin', () => {
+ expect(() =>
+ resolveAppNodeSpecs({
+ features: [
+ createPlugin({ id: 'test', extensions: [makeExt('forbidden')] }),
+ ],
+ builtinExtensions: [],
+ parameters: [],
+ forbidden: new Set(['forbidden']),
+ }),
+ ).toThrow(
+ "It is forbidden to override the following extension(s): 'forbidden', which is done by the following plugin(s): 'test'",
+ );
+ });
+
+ it('throws an error when a forbidden extension is overridden by overrides', () => {
+ expect(() =>
+ resolveAppNodeSpecs({
+ features: [
+ createExtensionOverrides({ extensions: [makeExt('forbidden')] }),
+ ],
+ builtinExtensions: [],
+ parameters: [],
+ forbidden: new Set(['forbidden']),
+ }),
+ ).toThrow(
+ "It is forbidden to override the following extension(s): 'forbidden', which is done by one or more extension overrides",
+ );
+ });
+
+ it('throws an error when a forbidden extension is parametrized', () => {
+ expect(() =>
+ resolveAppNodeSpecs({
+ features: [],
+ builtinExtensions: [],
+ parameters: [{ id: 'forbidden', disabled: false }],
+ forbidden: new Set(['forbidden']),
+ }),
+ ).toThrow("Configuration of the 'forbidden' extension is forbidden");
+ });
+});
diff --git a/packages/frontend-app-api/src/graph/resolveAppNodeSpecs.ts b/packages/frontend-app-api/src/graph/resolveAppNodeSpecs.ts
new file mode 100644
index 0000000000..ca3c4fd1ea
--- /dev/null
+++ b/packages/frontend-app-api/src/graph/resolveAppNodeSpecs.ts
@@ -0,0 +1,212 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ BackstagePlugin,
+ Extension,
+ ExtensionOverrides,
+} from '@backstage/frontend-plugin-api';
+// eslint-disable-next-line @backstage/no-relative-monorepo-imports
+import { toInternalExtensionOverrides } from '../../../frontend-plugin-api/src/wiring/createExtensionOverrides';
+import { ExtensionParameters } from './readAppExtensionsConfig';
+import { AppNodeSpec } from './types';
+
+/** @internal */
+export function resolveAppNodeSpecs(options: {
+ features: (BackstagePlugin | ExtensionOverrides)[];
+ builtinExtensions: Extension[];
+ parameters: Array;
+ forbidden?: Set;
+}): AppNodeSpec[] {
+ const { builtinExtensions, parameters, forbidden = new Set() } = options;
+
+ const plugins = options.features.filter(
+ (f): f is BackstagePlugin => f.$$type === '@backstage/BackstagePlugin',
+ );
+ const overrides = options.features.filter(
+ (f): f is ExtensionOverrides =>
+ f.$$type === '@backstage/ExtensionOverrides',
+ );
+
+ const pluginExtensions = plugins.flatMap(source => {
+ return source.extensions.map(extension => ({ ...extension, source }));
+ });
+ const overrideExtensions = overrides.flatMap(
+ override => toInternalExtensionOverrides(override).extensions,
+ );
+
+ // Prevent core override
+ if (pluginExtensions.some(({ id }) => forbidden.has(id))) {
+ const pluginsStr = pluginExtensions
+ .filter(({ id }) => forbidden.has(id))
+ .map(({ source }) => `'${source.id}'`)
+ .join(', ');
+ const forbiddenStr = [...forbidden].map(id => `'${id}'`).join(', ');
+ throw new Error(
+ `It is forbidden to override the following extension(s): ${forbiddenStr}, which is done by the following plugin(s): ${pluginsStr}`,
+ );
+ }
+
+ if (overrideExtensions.some(({ id }) => forbidden.has(id))) {
+ const forbiddenStr = [...forbidden].map(id => `'${id}'`).join(', ');
+ throw new Error(
+ `It is forbidden to override the following extension(s): ${forbiddenStr}, which is done by one or more extension overrides`,
+ );
+ }
+ const overrideExtensionIds = overrideExtensions.map(({ id }) => id);
+ if (overrideExtensionIds.length !== new Set(overrideExtensionIds).size) {
+ const counts = new Map();
+ for (const id of overrideExtensionIds) {
+ counts.set(id, (counts.get(id) ?? 0) + 1);
+ }
+ const duplicated = Array.from(counts.entries())
+ .filter(([, count]) => count > 1)
+ .map(([id]) => id);
+ throw new Error(
+ `The following extensions had duplicate overrides: ${duplicated.join(
+ ', ',
+ )}`,
+ );
+ }
+
+ const configuredExtensions = [
+ ...pluginExtensions.map(({ source, ...extension }) => ({
+ extension,
+ params: {
+ source,
+ attachTo: extension.attachTo,
+ disabled: extension.disabled,
+ config: undefined as unknown,
+ },
+ })),
+ ...builtinExtensions.map(extension => ({
+ extension,
+ params: {
+ source: undefined,
+ attachTo: extension.attachTo,
+ disabled: extension.disabled,
+ config: undefined as unknown,
+ },
+ })),
+ ];
+
+ // Install all extension overrides
+ for (const extension of overrideExtensions) {
+ // Check if our override is overriding an extension that already exists
+ const index = configuredExtensions.findIndex(
+ e => e.extension.id === extension.id,
+ );
+ if (index !== -1) {
+ // Only implementation, attachment point and default disabled status are overridden, the source is kept
+ configuredExtensions[index].extension = extension;
+ configuredExtensions[index].params.attachTo = extension.attachTo;
+ configuredExtensions[index].params.disabled = extension.disabled;
+ } else {
+ // Add the extension as a new one when not overriding an existing one
+ configuredExtensions.push({
+ extension,
+ params: {
+ source: undefined,
+ attachTo: extension.attachTo,
+ disabled: extension.disabled,
+ config: undefined,
+ },
+ });
+ }
+ }
+
+ const duplicatedExtensionIds = new Set();
+ const duplicatedExtensionData = configuredExtensions.reduce<
+ Record>
+ >((data, { extension, params }) => {
+ const extensionId = extension.id;
+ const extensionData = data?.[extensionId];
+ if (extensionData) duplicatedExtensionIds.add(extensionId);
+ const pluginId = params.source?.id ?? 'internal';
+ const pluginCount = extensionData?.[pluginId] ?? 0;
+ return {
+ ...data,
+ [extensionId]: { ...extensionData, [pluginId]: pluginCount + 1 },
+ };
+ }, {});
+
+ if (duplicatedExtensionIds.size > 0) {
+ throw new Error(
+ `The following extensions are duplicated: ${Array.from(
+ duplicatedExtensionIds,
+ )
+ .map(
+ extensionId =>
+ `The extension '${extensionId}' was provided ${Object.keys(
+ duplicatedExtensionData[extensionId],
+ )
+ .map(
+ pluginId =>
+ `${duplicatedExtensionData[extensionId][pluginId]} time(s) by the plugin '${pluginId}'`,
+ )
+ .join(' and ')}`,
+ )
+ .join(', ')}`,
+ );
+ }
+
+ for (const overrideParam of parameters) {
+ const extensionId = overrideParam.id;
+
+ if (forbidden.has(extensionId)) {
+ throw new Error(
+ `Configuration of the '${extensionId}' extension is forbidden`,
+ );
+ }
+
+ const existingIndex = configuredExtensions.findIndex(
+ e => e.extension.id === extensionId,
+ );
+ if (existingIndex !== -1) {
+ const existing = configuredExtensions[existingIndex];
+ if (overrideParam.attachTo) {
+ existing.params.attachTo = overrideParam.attachTo;
+ }
+ if (overrideParam.config) {
+ // TODO: merge config?
+ existing.params.config = overrideParam.config;
+ }
+ if (
+ Boolean(existing.params.disabled) !== Boolean(overrideParam.disabled)
+ ) {
+ existing.params.disabled = Boolean(overrideParam.disabled);
+ if (!existing.params.disabled) {
+ // bump
+ configuredExtensions.splice(existingIndex, 1);
+ configuredExtensions.push(existing);
+ }
+ }
+ } else {
+ throw new Error(`Extension ${extensionId} does not exist`);
+ }
+ }
+
+ return configuredExtensions
+ .filter(override => !override.params.disabled)
+ .map(param => ({
+ id: param.extension.id,
+ attachTo: param.params.attachTo,
+ extension: param.extension,
+ disabled: param.params.disabled,
+ source: param.params.source,
+ config: param.params.config,
+ }));
+}
diff --git a/packages/frontend-app-api/src/graph/types.ts b/packages/frontend-app-api/src/graph/types.ts
new file mode 100644
index 0000000000..9267f13158
--- /dev/null
+++ b/packages/frontend-app-api/src/graph/types.ts
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ BackstagePlugin,
+ Extension,
+ ExtensionDataRef,
+} from '@backstage/frontend-plugin-api';
+
+/*
+NOTE: These types are marked as @internal for now, but the intention is for this to be a public API in the future.
+*/
+
+/**
+ * The specification for this node in the app graph.
+ *
+ * @internal
+ * @remarks
+ *
+ * The specifications for a collection of app nodes is all the information needed
+ * to build the graph and instantiate the nodes.
+ */
+export interface AppNodeSpec {
+ readonly id: string;
+ readonly attachTo: { id: string; input: string };
+ readonly extension: Extension;
+ readonly disabled: boolean;
+ readonly config?: unknown;
+ readonly source?: BackstagePlugin;
+}
+
+/**
+ * The connections from this node to other nodes.
+ *
+ * @internal
+ * @remarks
+ *
+ * The app node edges are resolved based on the app node specs, regardless of whether
+ * adjacent nodes are disabled or not. If no parent attachment is present or
+ */
+export interface AppNodeEdges {
+ readonly attachedTo?: { node: AppNode; input: string };
+ readonly attachments: ReadonlyMap;
+}
+
+/**
+ * The instance of this node in the app graph.
+ *
+ * @internal
+ * @remarks
+ *
+ * The app node instance is created when the `factory` function of an extension is called.
+ * Instances will only be present for nodes in the app that are connected to the root
+ * node and not disabled
+ */
+export interface AppNodeInstance {
+ /** Returns a sequence of all extension data refs that were output by this instance */
+ getDataRefs(): Iterable>;
+ /** Get the output data for a single extension data ref */
+ getData(ref: ExtensionDataRef): T | undefined;
+}
+
+/**
+ *
+ * @internal
+ */
+export interface AppNode {
+ /** The specification for how this node should be instantiated */
+ readonly spec: AppNodeSpec;
+ /** The edges from this node to other nodes in the app graph */
+ readonly edges: AppNodeEdges;
+ /** The instance of this node, if it was instantiated */
+ readonly instance?: AppNodeInstance;
+}
+
+/**
+ * The app graph containing all nodes of the app.
+ *
+ * @internal
+ */
+export interface AppGraph {
+ /** The root node of the app */
+ root: AppNode;
+ /** A map of all nodes in the app by ID, including orphaned or disabled nodes */
+ nodes: ReadonlyMap;
+ /** A sequence of all nodes with a parent that is not reachable from the app root node */
+ orphans: Iterable;
+}
diff --git a/packages/frontend-app-api/src/routing/RouteResolver.test.ts b/packages/frontend-app-api/src/routing/RouteResolver.test.ts
index e37ee14929..81e9d10be4 100644
--- a/packages/frontend-app-api/src/routing/RouteResolver.test.ts
+++ b/packages/frontend-app-api/src/routing/RouteResolver.test.ts
@@ -24,7 +24,7 @@ import {
} from '@backstage/frontend-plugin-api';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { RouteResolver } from './RouteResolver';
-import { MATCH_ALL_ROUTE } from './extractRouteInfoFromInstanceTree';
+import { MATCH_ALL_ROUTE } from './extractRouteInfoFromAppNode';
const rest = {
element: null,
diff --git a/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.test.ts b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts
similarity index 96%
rename from packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.test.ts
rename to packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts
index 0de33b50f2..e0adf3062a 100644
--- a/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.test.ts
+++ b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts
@@ -16,7 +16,7 @@
import React from 'react';
import { BackstagePlugin } from '@backstage/core-plugin-api';
-import { extractRouteInfoFromInstanceTree } from './extractRouteInfoFromInstanceTree';
+import { extractRouteInfoFromAppNode } from './extractRouteInfoFromAppNode';
import {
AnyRouteRefParams,
Extension,
@@ -27,8 +27,12 @@ import {
createPlugin,
createRouteRef,
} from '@backstage/frontend-plugin-api';
-import { createInstances } from '../wiring/createApp';
import { MockConfigApi } from '@backstage/test-utils';
+import { createAppGraph } from '../graph';
+import { Core } from '../extensions/Core';
+import { CoreRoutes } from '../extensions/CoreRoutes';
+import { CoreNav } from '../extensions/CoreNav';
+import { CoreLayout } from '../extensions/CoreLayout';
const ref1 = createRouteRef();
const ref2 = createRouteRef();
@@ -73,12 +77,13 @@ function routeInfoFromExtensions(extensions: Extension[]) {
id: 'test',
extensions,
});
- const { coreInstance } = createInstances({
+ const graph = createAppGraph({
config: new MockConfigApi({}),
+ builtinExtensions: [Core, CoreRoutes, CoreNav, CoreLayout],
features: [plugin],
});
- return extractRouteInfoFromInstanceTree(coreInstance);
+ return extractRouteInfoFromAppNode(graph.root);
}
function sortedEntries(map: Map): [RouteRef, T][] {
diff --git a/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.ts b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.ts
similarity index 91%
rename from packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.ts
rename to packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.ts
index 0bf9c2d1dd..71991ee44e 100644
--- a/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.ts
+++ b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.ts
@@ -15,10 +15,10 @@
*/
import { RouteRef, coreExtensionData } from '@backstage/frontend-plugin-api';
-import { ExtensionInstance } from '../wiring/createExtensionInstance';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toLegacyPlugin } from '../wiring/createApp';
import { BackstageRouteObject } from './types';
+import { AppNode } from '../graph';
// We always add a child that matches all subroutes but without any route refs. This makes
// sure that we're always able to match each route no matter how deep the navigation goes.
@@ -41,7 +41,7 @@ export function joinPaths(...paths: string[]): string {
return normalized;
}
-export function extractRouteInfoFromInstanceTree(core: ExtensionInstance): {
+export function extractRouteInfoFromAppNode(node: AppNode): {
routePaths: Map;
routeParents: Map;
routeObjects: BackstageRouteObject[];
@@ -56,17 +56,17 @@ export function extractRouteInfoFromInstanceTree(core: ExtensionInstance): {
const routeObjects = new Array();
function visit(
- current: ExtensionInstance,
+ current: AppNode,
collectedPath?: string,
foundRefForCollectedPath: boolean = false,
parentRef?: RouteRef,
candidateParentRef?: RouteRef,
parentObj?: BackstageRouteObject,
) {
- const routePath = current
- .getData(coreExtensionData.routePath)
+ const routePath = current.instance
+ ?.getData(coreExtensionData.routePath)
?.replace(/^\//, '');
- const routeRef = current.getData(coreExtensionData.routeRef);
+ const routeRef = current.instance?.getData(coreExtensionData.routeRef);
const parentChildren = parentObj?.children ?? routeObjects;
let currentObj = parentObj;
@@ -124,12 +124,12 @@ export function extractRouteInfoFromInstanceTree(core: ExtensionInstance): {
routeParents.set(routeRef, newParentRef);
currentObj?.routeRefs.add(routeRef);
- if (current.source) {
- currentObj?.plugins.add(toLegacyPlugin(current.source));
+ if (current.spec.source) {
+ currentObj?.plugins.add(toLegacyPlugin(current.spec.source));
}
}
- for (const children of current.attachments.values()) {
+ for (const children of current.edges.attachments.values()) {
for (const child of children) {
visit(
child,
@@ -143,7 +143,7 @@ export function extractRouteInfoFromInstanceTree(core: ExtensionInstance): {
}
}
- visit(core);
+ visit(node);
return { routePaths, routeParents, routeObjects };
}
diff --git a/packages/frontend-app-api/src/wiring/createApp.test.tsx b/packages/frontend-app-api/src/wiring/createApp.test.tsx
index de574bc6a9..e9fb48f957 100644
--- a/packages/frontend-app-api/src/wiring/createApp.test.tsx
+++ b/packages/frontend-app-api/src/wiring/createApp.test.tsx
@@ -15,123 +15,15 @@
*/
import {
- createExtension,
- createExtensionOverrides,
createPageExtension,
createPlugin,
- createRouteRef,
createThemeExtension,
} from '@backstage/frontend-plugin-api';
-import { createApp, createInstances } from './createApp';
import { screen, waitFor } from '@testing-library/react';
+import { createApp } from './createApp';
import { MockConfigApi, renderWithEffects } from '@backstage/test-utils';
import React from 'react';
-const extBaseConfig = {
- id: 'test',
- attachTo: { id: 'root', input: 'default' },
- output: {},
- factory() {},
-};
-
-describe('createInstances', () => {
- it('throws an error when a core extension is parametrized', () => {
- const config = new MockConfigApi({
- app: {
- extensions: [
- {
- core: {},
- },
- ],
- },
- });
- const features = [
- createPlugin({
- id: 'plugin',
- extensions: [],
- }),
- ];
- expect(() => createInstances({ config, features })).toThrow(
- "A 'core' extension configuration was detected, but the core extension is not configurable",
- );
- });
-
- it('throws an error when a core extension is overridden', () => {
- const config = new MockConfigApi({});
- const features = [
- createPlugin({
- id: 'plugin',
- extensions: [
- createExtension({
- id: 'core',
- attachTo: { id: 'core.routes', input: 'route' },
- inputs: {},
- output: {},
- factory() {},
- }),
- ],
- }),
- ];
- expect(() => createInstances({ config, features })).toThrow(
- "The following plugin(s) are overriding the 'core' extension which is forbidden: plugin",
- );
- });
-
- it('throws an error when duplicated extensions are detected', () => {
- const config = new MockConfigApi({});
-
- const ExtensionA = createPageExtension({
- id: 'A',
- defaultPath: '/',
- routeRef: createRouteRef(),
- loader: async () =>