diff --git a/.changeset/afraid-experts-explain.md b/.changeset/afraid-experts-explain.md new file mode 100644 index 0000000000..38eb3709d0 --- /dev/null +++ b/.changeset/afraid-experts-explain.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-node': patch +--- + +Added `type` field to `BackstagePackageJson` type. diff --git a/.changeset/beige-dingos-destroy.md b/.changeset/beige-dingos-destroy.md new file mode 100644 index 0000000000..325c150119 --- /dev/null +++ b/.changeset/beige-dingos-destroy.md @@ -0,0 +1,18 @@ +--- +'@backstage/cli': minor +--- + +**BREAKING**: Add support for native ESM in Node.js code. This changes the behavior of dynamic import expressions in Node.js code. Typically this can be fixed by replacing `import(...)` with `require(...)`, with an `as typeof import(...)` cast if needed for types. This is because dynamic imports will no longer be transformed to `require(...)` calls, but instead be left as-is. This in turn allows you to load ESM modules from CommonJS code using `import(...)`. + +This change adds support for the following in Node.js packages, across type checking, package builds, runtime transforms and Jest tests: + +- Dynamic imports that load ESM modules from CommonJS code. +- Both `.mjs` and `.mts` files as explicit ESM files, as well as `.cjs` and `.cts` as explicit CommonJS files. +- Support for the `"type": "module"` field in `package.json` to indicate that the package is an ESM package. + +There are a few caveats to be aware of: + +- To enable support for native ESM in tests, you need to run the tests with the `--experimental-vm-modules` flag enabled, typically via `NODE_OPTIONS='--experimental-vm-modules'`. +- Declaring a package as `"type": "module"` in `package.json` is supported, but in tests it will cause all local transitive dependencies to also be treated as ESM, regardless of whether they declare `"type": "module"` or not. +- Node.js has an [ESM interoperability layer with CommonJS](https://nodejs.org/docs/latest-v22.x/api/esm.html#interoperability-with-commonjs) that allows for imports from ESM to identify named exports in CommonJS packages. This interoperability layer is **only** enabled when importing packages with a `.cts` or `.cjs` extension. This is because the interoperability layer is not fully compatible with the NPM ecosystem, and would break package if it was enabled for `.js` files. +- Dynamic imports of CommonJS packages will vary in shape depending on the runtime, i.e. test vs local development, etc. It is therefore recommended to avoid dynamic imports of CommonJS packages and instead use `require`, or to use the explicit CommonJS extensions as mentioned above. If you do need to dynamically import CommonJS packages, avoid using `default` exports, as the shape of them vary across different environments and you would otherwise need to manually unwrap the import based on the shape of the module object. diff --git a/.changeset/clean-squids-build.md b/.changeset/clean-squids-build.md new file mode 100644 index 0000000000..3c1e0b56a7 --- /dev/null +++ b/.changeset/clean-squids-build.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-notifications-backend': patch +'@backstage/plugin-notifications': patch +--- + +added topic filter for notifications diff --git a/.changeset/curly-humans-prove.md b/.changeset/curly-humans-prove.md new file mode 100644 index 0000000000..4e0ca9c90e --- /dev/null +++ b/.changeset/curly-humans-prove.md @@ -0,0 +1,7 @@ +--- +'@backstage/backend-test-utils': patch +'@backstage/backend-defaults': patch +'@backstage/config-loader': patch +--- + +Internal refactor to use explicit `require` for lazy-loading dependency. diff --git a/.changeset/dry-horses-report.md b/.changeset/dry-horses-report.md new file mode 100644 index 0000000000..b7a0b4cd46 --- /dev/null +++ b/.changeset/dry-horses-report.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-test-utils': patch +--- + +Sync feature installation compatibility logic with `@backstage/backend-app-api`. diff --git a/.changeset/fair-mangos-sleep.md b/.changeset/fair-mangos-sleep.md new file mode 100644 index 0000000000..8f0a6371c3 --- /dev/null +++ b/.changeset/fair-mangos-sleep.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Cleanup `refresh_state_references` for entity processors and providers that are no longer in control of a `refresh_state` row for entity diff --git a/.changeset/quiet-phones-sell.md b/.changeset/quiet-phones-sell.md new file mode 100644 index 0000000000..f41f5d685e --- /dev/null +++ b/.changeset/quiet-phones-sell.md @@ -0,0 +1,5 @@ +--- +'@backstage/repo-tools': patch +--- + +Internal refactor to support native ESM. diff --git a/.changeset/sharp-years-drive.md b/.changeset/sharp-years-drive.md new file mode 100644 index 0000000000..92ade2b972 --- /dev/null +++ b/.changeset/sharp-years-drive.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search-backend-module-catalog': patch +--- + +Modified the logic for generating the location URL by encoding the entity property values with `encodeURIComponent`. This enhancement improves the safety and reliability of the URL. diff --git a/.changeset/twelve-eyes-stare.md b/.changeset/twelve-eyes-stare.md new file mode 100644 index 0000000000..bd2db50c9b --- /dev/null +++ b/.changeset/twelve-eyes-stare.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-dynamic-feature-service': patch +--- + +Make sure changes are successfully tracked before starting up scanner. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 951900dd49..bcf5ed94a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -202,7 +202,7 @@ jobs: env: CI: true - NODE_OPTIONS: --max-old-space-size=8192 --no-node-snapshot + NODE_OPTIONS: --max-old-space-size=8192 --no-node-snapshot --experimental-vm-modules INTEGRATION_TEST_GITHUB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITHUB_TOKEN }} INTEGRATION_TEST_GITLAB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITLAB_TOKEN }} INTEGRATION_TEST_BITBUCKET_TOKEN: ${{ secrets.INTEGRATION_TEST_BITBUCKET_TOKEN }} diff --git a/.github/workflows/verify_windows.yml b/.github/workflows/verify_windows.yml index 01160254f4..1eee6e61f3 100644 --- a/.github/workflows/verify_windows.yml +++ b/.github/workflows/verify_windows.yml @@ -21,7 +21,7 @@ jobs: env: CI: true - NODE_OPTIONS: --max-old-space-size=8192 --no-node-snapshot + NODE_OPTIONS: --max-old-space-size=8192 --no-node-snapshot --experimental-vm-modules INTEGRATION_TEST_GITHUB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITHUB_TOKEN }} INTEGRATION_TEST_GITLAB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITLAB_TOKEN }} INTEGRATION_TEST_BITBUCKET_TOKEN: ${{ secrets.INTEGRATION_TEST_BITBUCKET_TOKEN }} diff --git a/canon-docs/package.json b/canon-docs/package.json index 824a645f99..9fddf08c20 100644 --- a/canon-docs/package.json +++ b/canon-docs/package.json @@ -9,11 +9,15 @@ "watch-css": "node scripts/watch-css.js" }, "dependencies": { + "@codemirror/lang-sass": "^6.0.2", + "@lezer/highlight": "^1.2.1", "@mdx-js/loader": "^3.1.0", "@mdx-js/react": "^3.1.0", "@next/mdx": "^15.1.4", "@storybook/react": "^8.4.7", "@types/mdx": "^2.0.13", + "@uiw/codemirror-themes": "^4.23.7", + "@uiw/react-codemirror": "^4.23.7", "next": "14.2.23", "react": "^18", "react-dom": "^18", diff --git a/canon-docs/src/app/globals.css b/canon-docs/src/app/globals.css index 4b39735a22..5efc0407e7 100644 --- a/canon-docs/src/app/globals.css +++ b/canon-docs/src/app/globals.css @@ -35,3 +35,37 @@ iframe { font-weight: var(--shiki-dark-font-weight) !important; text-decoration: var(--shiki-dark-text-decoration) !important; } + +.cm-editor { + transition: background-color 0.2s ease-in-out; +} + +.ͼ2 .cm-gutters { + background-color: var(--canon-surface-2); + border-right: 1px solid var(--canon-border-base); + transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out; +} + +.ͼ1 .cm-lineNumbers .cm-gutterElement { + padding-right: 8px; + padding-left: 8px; + font-size: 0.875rem; + display: flex; + align-items: center; + justify-content: flex-end; + min-width: 34px; +} + +.cm-line { + font-size: 0.875rem; +} + +.ͼ1 .cm-line { + padding-left: 12px; + padding-top: 1px; + padding-bottom: 1px; +} + +.cm-focused { + outline: none !important; +} diff --git a/canon-docs/src/app/layout.tsx b/canon-docs/src/app/layout.tsx index 6eb1856a1d..99b5f22739 100644 --- a/canon-docs/src/app/layout.tsx +++ b/canon-docs/src/app/layout.tsx @@ -2,7 +2,7 @@ import type { Metadata } from 'next'; import { Sidebar } from '../components/Sidebar'; import { Toolbar } from '@/components/Toolbar'; import { Providers } from './providers'; - +import { CustomTheme } from '@/components/CustomTheme'; import styles from './page.module.css'; import './globals.css'; @@ -13,6 +13,7 @@ import '/public/backstage.css'; export const metadata: Metadata = { title: 'Canon', description: 'UI library for Backstage', + metadataBase: new URL('https://canon.backstage.io'), }; export default function RootLayout({ @@ -35,6 +36,7 @@ export default function RootLayout({ {children} + diff --git a/canon-docs/src/components/CustomTheme/customTheme.tsx b/canon-docs/src/components/CustomTheme/customTheme.tsx new file mode 100644 index 0000000000..3d8ed394bd --- /dev/null +++ b/canon-docs/src/components/CustomTheme/customTheme.tsx @@ -0,0 +1,147 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import CodeMirror from '@uiw/react-codemirror'; +import { sass } from '@codemirror/lang-sass'; +import styles from './styles.module.css'; +import { usePlayground } from '@/utils/playground-context'; +import { AnimatePresence, motion } from 'framer-motion'; +import { Icon } from '../../../../packages/canon'; +import { createTheme } from '@uiw/codemirror-themes'; +import { tags as t } from '@lezer/highlight'; + +const defaultTheme = `:root { + --canon-accent: #000; +}`; + +const myTheme = createTheme({ + theme: 'light', + settings: { + background: 'var(--canon-surface-1)', + backgroundImage: '', + foreground: '#6182B8', + caret: '#5d00ff', + selection: '#036dd626', + selectionMatch: '#036dd626', + lineHighlight: '#8a91991a', + gutterBackground: '#fff', + gutterForeground: '#8a919966', + }, + styles: [ + { tag: t.comment, color: '#787b8099' }, + { tag: t.variableName, color: '#0080ff' }, + { tag: [t.string, t.special(t.brace)], color: '#6182B8' }, + { tag: t.number, color: '#6182B8' }, + { tag: t.bool, color: '#6182B8' }, + { tag: t.null, color: '#6182B8' }, + { tag: t.keyword, color: '#6182B8' }, + { tag: t.operator, color: '#6182B8' }, + { tag: t.className, color: '#6182B8' }, + { tag: t.definition(t.typeName), color: '#6182B8' }, + { tag: t.typeName, color: '#6182B8' }, + { tag: t.angleBracket, color: '#6182B8' }, + { tag: t.tagName, color: '#6182B8' }, + { tag: t.attributeName, color: '#6182B8' }, + ], +}); + +export const CustomTheme = () => { + const [isClient, setIsClient] = useState(false); + const [open, setOpen] = useState(true); + const [customTheme, setCustomTheme] = useState(undefined); + const { selectedThemeName } = usePlayground(); + const [savedMessage, setSavedMessage] = useState('Save'); + + const updateStyleElement = (theme: string) => { + let styleElement = document.getElementById( + 'custom-theme-style', + ) as HTMLStyleElement; + + if (!styleElement) { + styleElement = document.createElement('style'); + styleElement.id = 'custom-theme-style'; + document.head.appendChild(styleElement); + } + + styleElement.textContent = theme; + }; + + useEffect(() => { + if (selectedThemeName === 'custom') { + let storedTheme = localStorage.getItem('customThemeCss'); + if (!storedTheme) { + storedTheme = defaultTheme; + localStorage.setItem('customThemeCss', storedTheme); + } + setCustomTheme(storedTheme); + updateStyleElement(storedTheme); + } else { + const styleElement = document.getElementById( + 'custom-theme-style', + ) as HTMLStyleElement; + if (styleElement) { + styleElement.remove(); + } + } + }, [selectedThemeName]); + + useEffect(() => { + setIsClient(true); + }, []); + + const handleSave = () => { + if (customTheme) { + localStorage.setItem('customThemeCss', customTheme); + updateStyleElement(customTheme); + setSavedMessage('Saved!'); + setTimeout(() => setSavedMessage('Save'), 1000); + } + }; + + const handleChange = useCallback((val: string) => { + setCustomTheme(val); + }, []); + + if (isClient === false) return null; + + return ( + + {selectedThemeName === 'custom' && ( + +
+
Custom Theme
+
+ {open && ( + + )} + +
+
+
+ +
+
+ )} +
+ ); +}; diff --git a/canon-docs/src/components/CustomTheme/index.ts b/canon-docs/src/components/CustomTheme/index.ts new file mode 100644 index 0000000000..6805b2c087 --- /dev/null +++ b/canon-docs/src/components/CustomTheme/index.ts @@ -0,0 +1 @@ +export { CustomTheme } from './customTheme'; diff --git a/canon-docs/src/components/CustomTheme/styles.module.css b/canon-docs/src/components/CustomTheme/styles.module.css new file mode 100644 index 0000000000..a4f51c8a1d --- /dev/null +++ b/canon-docs/src/components/CustomTheme/styles.module.css @@ -0,0 +1,77 @@ +.container { + position: fixed; + bottom: 16px; + right: 16px; + width: 240px; + height: 47px; + background-color: var(--canon-surface-1); + border-radius: 0.375rem; + border: 1px solid var(--canon-border-base); + display: flex; + flex-direction: column; + overflow: hidden; + transition-property: background-color, border-color, height, width; + transition-duration: 0.2s; + transition-timing-function: ease-in-out; +} + +.open { + width: 36%; + height: 348px; +} + +.editor { + flex: 1; +} + +.editorContainer { + overflow: hidden; +} + +.header { + height: 46px; + flex-shrink: 0; + border-bottom: 1px solid var(--canon-border-base); + background-color: var(--canon-surface-1); + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 12px 0 16px; + transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out; +} + +.headerLeft { + font-size: 0.875rem; +} + +.headerRight { + display: flex; + gap: 8px; +} + +.buttonSave { + all: unset; + height: 28px; + padding: 0 8px; + color: #fff; + background-color: #000; + border-radius: 0.25rem; + cursor: pointer; + font-size: 0.75rem; +} + +.buttonClose { + all: unset; + height: 28px; + padding: 0 8px; + color: #fff; + background-color: var(--canon-surface-2); + color: var(--canon-text-primary); + transition: background-color 0.2s ease-in-out; + border-radius: 0.25rem; + cursor: pointer; + font-size: 0.875rem; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/canon-docs/src/components/Sidebar/Sidebar.module.css b/canon-docs/src/components/Sidebar/Sidebar.module.css index 870f504d28..f3bdd3f2e7 100644 --- a/canon-docs/src/components/Sidebar/Sidebar.module.css +++ b/canon-docs/src/components/Sidebar/Sidebar.module.css @@ -35,15 +35,11 @@ position: relative; } -.menu a { - display: flex; - text-decoration: none; - height: 28px; - align-items: center; -} - .section { width: 100%; + display: flex; + flex-direction: column; + gap: 2px; } .sectionTitle { @@ -54,12 +50,14 @@ } .line { + text-decoration: none; + align-items: center; width: 100%; display: flex; flex-direction: row; justify-content: space-between; align-items: center; - height: 28px; + height: 26px; padding: 0 12px; border-radius: 4px; transition: background-color 0.2s ease-in-out; diff --git a/canon-docs/src/components/Sidebar/docs.tsx b/canon-docs/src/components/Sidebar/docs.tsx index e30ad50750..f207036cfa 100644 --- a/canon-docs/src/components/Sidebar/docs.tsx +++ b/canon-docs/src/components/Sidebar/docs.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import { components, overview, layoutComponents, theme } from '@/utils/data'; -import { Box } from '../../../../packages/canon/src/components/Box'; +import { Box } from '../../../../packages/canon'; import { motion } from 'framer-motion'; import styles from './Sidebar.module.css'; import { usePathname } from 'next/navigation'; diff --git a/canon-docs/src/components/Toolbar/theme-name.module.css b/canon-docs/src/components/Toolbar/theme-name.module.css index 6f2ceb427f..c9700c571b 100644 --- a/canon-docs/src/components/Toolbar/theme-name.module.css +++ b/canon-docs/src/components/Toolbar/theme-name.module.css @@ -43,7 +43,7 @@ box-sizing: border-box; padding-block: 0.25rem; border-radius: 0.375rem; - background-color: canvas; + background-color: var(--canon-surface-1); color: var(--color-gray-900); border: 1px solid var(--canon-border-base); padding-inline: 0.25rem; diff --git a/canon-docs/src/components/Toolbar/theme-name.tsx b/canon-docs/src/components/Toolbar/theme-name.tsx index 58c6674134..07b734de34 100644 --- a/canon-docs/src/components/Toolbar/theme-name.tsx +++ b/canon-docs/src/components/Toolbar/theme-name.tsx @@ -6,8 +6,9 @@ import { Icon } from '@backstage/canon'; import { usePlayground } from '@/utils/playground-context'; const themes = [ - { name: 'Backstage Legacy', value: 'legacy' }, { name: 'Backstage Default', value: 'default' }, + { name: 'Backstage Legacy', value: 'legacy' }, + { name: 'Custom theme', value: 'custom' }, ]; export const ThemeNameSelector = () => { diff --git a/canon-docs/src/utils/playground-context.tsx b/canon-docs/src/utils/playground-context.tsx index 198c5a9aab..721851c3e4 100644 --- a/canon-docs/src/utils/playground-context.tsx +++ b/canon-docs/src/utils/playground-context.tsx @@ -7,16 +7,19 @@ import React, { } from 'react'; import { components } from './data'; +type Theme = 'light' | 'dark'; +type ThemeName = 'legacy' | 'default' | 'custom'; + // Create a context with an empty array as the default value const PlaygroundContext = createContext<{ selectedScreenSizes: string[]; setSelectedScreenSizes: (screenSizes: string[]) => void; selectedComponents: string[]; setSelectedComponents: (components: string[]) => void; - selectedTheme: string | null; - setSelectedTheme: (theme: string) => void; - selectedThemeName: string; - setSelectedThemeName: (themeName: string) => void; + selectedTheme: Theme; + setSelectedTheme: (theme: Theme) => void; + selectedThemeName: ThemeName; + setSelectedThemeName: (themeName: ThemeName) => void; }>({ selectedScreenSizes: [], setSelectedScreenSizes: () => {}, @@ -37,12 +40,14 @@ export const PlaygroundProvider = ({ children }: { children: ReactNode }) => { const [selectedComponents, setSelectedComponents] = useState( components.map(component => component.slug), ); - const [selectedTheme, setSelectedTheme] = useState(() => { - return isBrowser ? localStorage.getItem('theme') : 'light'; - }); - const [selectedThemeName, setSelectedThemeName] = useState(() => { + const [selectedTheme, setSelectedTheme] = useState(() => { return isBrowser - ? localStorage.getItem('theme-name') || 'default' + ? (localStorage.getItem('theme') as Theme) || 'light' + : 'light'; + }); + const [selectedThemeName, setSelectedThemeName] = useState(() => { + return isBrowser + ? (localStorage.getItem('theme-name') as ThemeName) || 'default' : 'default'; }); diff --git a/canon-docs/yarn.lock b/canon-docs/yarn.lock index fd0b1f8742..e8b236002c 100644 --- a/canon-docs/yarn.lock +++ b/canon-docs/yarn.lock @@ -5,6 +5,133 @@ __metadata: version: 6 cacheKey: 8 +"@babel/runtime@npm:^7.18.6": + version: 7.26.0 + resolution: "@babel/runtime@npm:7.26.0" + dependencies: + regenerator-runtime: ^0.14.0 + checksum: c8e2c0504ab271b3467a261a8f119bf2603eb857a0d71e37791f4e3fae00f681365073cc79f141ddaa90c6077c60ba56448004ad5429d07ac73532be9f7cf28a + languageName: node + linkType: hard + +"@codemirror/autocomplete@npm:^6.0.0": + version: 6.18.4 + resolution: "@codemirror/autocomplete@npm:6.18.4" + dependencies: + "@codemirror/language": ^6.0.0 + "@codemirror/state": ^6.0.0 + "@codemirror/view": ^6.17.0 + "@lezer/common": ^1.0.0 + checksum: 4216f45a17f6cfd8d33df53f940396f7d3707662570bf3a79d8d333f926e273a265fac13c362e29e3fa57ccdf444f1a047862f5f56c672cfc669c87ee975858f + languageName: node + linkType: hard + +"@codemirror/commands@npm:^6.0.0, @codemirror/commands@npm:^6.1.0": + version: 6.8.0 + resolution: "@codemirror/commands@npm:6.8.0" + dependencies: + "@codemirror/language": ^6.0.0 + "@codemirror/state": ^6.4.0 + "@codemirror/view": ^6.27.0 + "@lezer/common": ^1.1.0 + checksum: 7d819bab4830ec7b8c5dffdec4b035dfa664bfd1d2675e639e08a459df65f45be111e1b8b569b1a8a3253d5980cf2ecf4394d8a13509996cca1b65cc16d47a4e + languageName: node + linkType: hard + +"@codemirror/lang-css@npm:^6.2.0": + version: 6.3.1 + resolution: "@codemirror/lang-css@npm:6.3.1" + dependencies: + "@codemirror/autocomplete": ^6.0.0 + "@codemirror/language": ^6.0.0 + "@codemirror/state": ^6.0.0 + "@lezer/common": ^1.0.2 + "@lezer/css": ^1.1.7 + checksum: ed175d75d75bc0a059d1e60b3dcd8464d570da14fc97388439943c9c43e1e9146e37b83fe2ccaad9cd387420b7b411ea1d24ede78ecd1f2045a38acbb4dd36bc + languageName: node + linkType: hard + +"@codemirror/lang-sass@npm:^6.0.2": + version: 6.0.2 + resolution: "@codemirror/lang-sass@npm:6.0.2" + dependencies: + "@codemirror/lang-css": ^6.2.0 + "@codemirror/language": ^6.0.0 + "@codemirror/state": ^6.0.0 + "@lezer/common": ^1.0.2 + "@lezer/sass": ^1.0.0 + checksum: e7665aaab70476a952522b143fd7bd59f6c025746cbf7b542f6965f94eecac483b4afd03f6da98aaa1572e379194309b241c5264eff05c681c637aa26651b9ab + languageName: node + linkType: hard + +"@codemirror/language@npm:^6.0.0": + version: 6.10.8 + resolution: "@codemirror/language@npm:6.10.8" + dependencies: + "@codemirror/state": ^6.0.0 + "@codemirror/view": ^6.23.0 + "@lezer/common": ^1.1.0 + "@lezer/highlight": ^1.0.0 + "@lezer/lr": ^1.0.0 + style-mod: ^4.0.0 + checksum: 679b69d69faa94f028f996a7005d0c6c2a2e4cd7a7a2614f615c23d7b642c31fc1837915248e864cb1ad59a2f032d1a7a8ef486b5f9904e5f6fbe6f7d2882c38 + languageName: node + linkType: hard + +"@codemirror/lint@npm:^6.0.0": + version: 6.8.4 + resolution: "@codemirror/lint@npm:6.8.4" + dependencies: + "@codemirror/state": ^6.0.0 + "@codemirror/view": ^6.35.0 + crelt: ^1.0.5 + checksum: 640e3dd44eb167d952eb5c5b8518919ba46e164aa3471776342f7f9361e676b4627a76a9f01d51b22127b97413f2bc9b8c60299d8dfdd5fc8ad0225d42de7669 + languageName: node + linkType: hard + +"@codemirror/search@npm:^6.0.0": + version: 6.5.8 + resolution: "@codemirror/search@npm:6.5.8" + dependencies: + "@codemirror/state": ^6.0.0 + "@codemirror/view": ^6.0.0 + crelt: ^1.0.5 + checksum: 0f9633037492a7b647b606c30255ea42c4327319e643be7ea3aa2913ed8e4aa662589f457e376636521c7d4d1215fae0e8939f127db9c0790b19ae3b654c3bc4 + languageName: node + linkType: hard + +"@codemirror/state@npm:^6.0.0, @codemirror/state@npm:^6.1.1, @codemirror/state@npm:^6.4.0, @codemirror/state@npm:^6.5.0": + version: 6.5.1 + resolution: "@codemirror/state@npm:6.5.1" + dependencies: + "@marijn/find-cluster-break": ^1.0.0 + checksum: b7d6de9a87d5b55dadfadaeb6e1c991e0a91845d3cdd0bd953391f05363fcbaf21de79a4eec2816ab8c3e31293faeca82cc2bb729d080779df94b14e28ae0d8a + languageName: node + linkType: hard + +"@codemirror/theme-one-dark@npm:^6.0.0": + version: 6.1.2 + resolution: "@codemirror/theme-one-dark@npm:6.1.2" + dependencies: + "@codemirror/language": ^6.0.0 + "@codemirror/state": ^6.0.0 + "@codemirror/view": ^6.0.0 + "@lezer/highlight": ^1.0.0 + checksum: 29bc09f79534115f62658caf3d0db527fe347d058b69a8c7f580ae636827377aadd0606fd0d83dbab8d6f3b0a5df53d3253c619341b5fb93d2c8291a8efb9556 + languageName: node + linkType: hard + +"@codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0, @codemirror/view@npm:^6.27.0, @codemirror/view@npm:^6.35.0": + version: 6.36.2 + resolution: "@codemirror/view@npm:6.36.2" + dependencies: + "@codemirror/state": ^6.5.0 + style-mod: ^4.1.0 + w3c-keyname: ^2.2.4 + checksum: a58c64b623ddc65bb864917297f3b37f8e95280deec442024c43a9513b26352c829665c5d98e4dfcae104e8ecdfdb774d94a395a29da98a919c83482d2c14152 + languageName: node + linkType: hard + "@esbuild/aix-ppc64@npm:0.24.2": version: 0.24.2 resolution: "@esbuild/aix-ppc64@npm:0.24.2" @@ -261,6 +388,60 @@ __metadata: languageName: node linkType: hard +"@lezer/common@npm:^1.0.0, @lezer/common@npm:^1.0.2, @lezer/common@npm:^1.1.0, @lezer/common@npm:^1.2.0": + version: 1.2.3 + resolution: "@lezer/common@npm:1.2.3" + checksum: 9b5f52d949adae69d077f56c0b1c2295923108c3dfb241dd9f17654ff708f3eab81ff9fa7f0d0e4a668eabdcb9d961c73e75caca87c966ca1436e30e49130fcb + languageName: node + linkType: hard + +"@lezer/css@npm:^1.1.7": + version: 1.1.9 + resolution: "@lezer/css@npm:1.1.9" + dependencies: + "@lezer/common": ^1.2.0 + "@lezer/highlight": ^1.0.0 + "@lezer/lr": ^1.0.0 + checksum: 25c63475061a3c9f87961a7f85c5f547f14fb7e81b0864675d2206999a874a0559d676145c74c6ccde39519dbc8aa33e216265f5366d08060507b6c9e875fe0f + languageName: node + linkType: hard + +"@lezer/highlight@npm:^1.0.0, @lezer/highlight@npm:^1.2.1": + version: 1.2.1 + resolution: "@lezer/highlight@npm:1.2.1" + dependencies: + "@lezer/common": ^1.0.0 + checksum: a8822d7e37f79ff64669eb2df4a9f9d16580e88f2b276a646092e19a9bdccac304e92510e200e35869a8b1f6c27eba5972c508d347a277e9b722d582ab7a23d5 + languageName: node + linkType: hard + +"@lezer/lr@npm:^1.0.0": + version: 1.4.2 + resolution: "@lezer/lr@npm:1.4.2" + dependencies: + "@lezer/common": ^1.0.0 + checksum: 94318ad046c7dfcc8d37e26cb85b99623c39aef60aa51ec2abb30928e7a649f38fa5520f34bd5b356f1db11b6991999589f039e87c8949b0f163be3764f029d8 + languageName: node + linkType: hard + +"@lezer/sass@npm:^1.0.0": + version: 1.0.7 + resolution: "@lezer/sass@npm:1.0.7" + dependencies: + "@lezer/common": ^1.2.0 + "@lezer/highlight": ^1.0.0 + "@lezer/lr": ^1.0.0 + checksum: 32d37aa5d3143033f0b886c33a293b1d28b7eecdf2cb979cf7b4cbba0587be227dfed246c27116060ac9b2b2c0fe4b09df4b672690dd0593e876529583e7ef2a + languageName: node + linkType: hard + +"@marijn/find-cluster-break@npm:^1.0.0": + version: 1.0.2 + resolution: "@marijn/find-cluster-break@npm:1.0.2" + checksum: 0d836de25e04d58325813401ef3c2d34caf040da985a5935fcbc9d84e7b47a21bdb15f57d70c2bf0960bd29ed3dbbb1afd00cdd0fc4fafbee7fd0ffe7d508ae1 + languageName: node + linkType: hard + "@mdx-js/loader@npm:^3.1.0": version: 3.1.0 resolution: "@mdx-js/loader@npm:3.1.0" @@ -904,6 +1085,66 @@ __metadata: languageName: node linkType: hard +"@uiw/codemirror-extensions-basic-setup@npm:4.23.7": + version: 4.23.7 + resolution: "@uiw/codemirror-extensions-basic-setup@npm:4.23.7" + dependencies: + "@codemirror/autocomplete": ^6.0.0 + "@codemirror/commands": ^6.0.0 + "@codemirror/language": ^6.0.0 + "@codemirror/lint": ^6.0.0 + "@codemirror/search": ^6.0.0 + "@codemirror/state": ^6.0.0 + "@codemirror/view": ^6.0.0 + peerDependencies: + "@codemirror/autocomplete": ">=6.0.0" + "@codemirror/commands": ">=6.0.0" + "@codemirror/language": ">=6.0.0" + "@codemirror/lint": ">=6.0.0" + "@codemirror/search": ">=6.0.0" + "@codemirror/state": ">=6.0.0" + "@codemirror/view": ">=6.0.0" + checksum: 656c5f78db4c6216a9a7eeb65455f52b002b7a5426bb1b05f11b896d5dba9887c7b9ee5aee9fe369d6c7b3c69128ee842cb5c8fe029cd07c2e48ec9358039a30 + languageName: node + linkType: hard + +"@uiw/codemirror-themes@npm:^4.23.7": + version: 4.23.7 + resolution: "@uiw/codemirror-themes@npm:4.23.7" + dependencies: + "@codemirror/language": ^6.0.0 + "@codemirror/state": ^6.0.0 + "@codemirror/view": ^6.0.0 + peerDependencies: + "@codemirror/language": ">=6.0.0" + "@codemirror/state": ">=6.0.0" + "@codemirror/view": ">=6.0.0" + checksum: 8c2a9a7ee8df2de3f09ad1f18979c4b407f7dd03e1f6ddfe47837b9e4769b566a70259ec8c398473b6b6db79cdb8809d4acc2f2ed7851743e13ff95308f52e33 + languageName: node + linkType: hard + +"@uiw/react-codemirror@npm:^4.23.7": + version: 4.23.7 + resolution: "@uiw/react-codemirror@npm:4.23.7" + dependencies: + "@babel/runtime": ^7.18.6 + "@codemirror/commands": ^6.1.0 + "@codemirror/state": ^6.1.1 + "@codemirror/theme-one-dark": ^6.0.0 + "@uiw/codemirror-extensions-basic-setup": 4.23.7 + codemirror: ^6.0.0 + peerDependencies: + "@babel/runtime": ">=7.11.0" + "@codemirror/state": ">=6.0.0" + "@codemirror/theme-one-dark": ">=6.0.0" + "@codemirror/view": ">=6.0.0" + codemirror: ">=6.0.0" + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 5f6318b290f479c4718129578bdccbbdf64e3b588fdd79e3d80224779219eae7e249d40e4f88c864ed6ae40ea93036042313e983a022349563698593a2bf2e6c + languageName: node + linkType: hard + "@ungap/structured-clone@npm:^1.0.0, @ungap/structured-clone@npm:^1.2.0": version: 1.2.1 resolution: "@ungap/structured-clone@npm:1.2.1" @@ -1254,6 +1495,8 @@ __metadata: version: 0.0.0-use.local resolution: "canon-docs@workspace:." dependencies: + "@codemirror/lang-sass": ^6.0.2 + "@lezer/highlight": ^1.2.1 "@mdx-js/loader": ^3.1.0 "@mdx-js/react": ^3.1.0 "@next/mdx": ^15.1.4 @@ -1262,6 +1505,8 @@ __metadata: "@types/node": ^20 "@types/react": ^18 "@types/react-dom": ^18 + "@uiw/codemirror-themes": ^4.23.7 + "@uiw/react-codemirror": ^4.23.7 concurrently: ^9.1.2 eslint: ^8 eslint-config-next: 14.2.23 @@ -1339,6 +1584,21 @@ __metadata: languageName: node linkType: hard +"codemirror@npm:^6.0.0": + version: 6.0.1 + resolution: "codemirror@npm:6.0.1" + dependencies: + "@codemirror/autocomplete": ^6.0.0 + "@codemirror/commands": ^6.0.0 + "@codemirror/language": ^6.0.0 + "@codemirror/lint": ^6.0.0 + "@codemirror/search": ^6.0.0 + "@codemirror/state": ^6.0.0 + "@codemirror/view": ^6.0.0 + checksum: 1a78f7077ac5801bdbff162aa0c61bf2b974603c7e9a477198c3ce50c789af674a061d7c293c58b73807eda345c2b5228c38ad2aabb9319d552d5486f785cbef + languageName: node + linkType: hard + "collapse-white-space@npm:^2.0.0": version: 2.1.0 resolution: "collapse-white-space@npm:2.1.0" @@ -1394,6 +1654,13 @@ __metadata: languageName: node linkType: hard +"crelt@npm:^1.0.5": + version: 1.0.6 + resolution: "crelt@npm:1.0.6" + checksum: dad842093371ad702afbc0531bfca2b0a8dd920b23a42f26e66dabbed9aad9acd5b9030496359545ef3937c3aced0fd4ac39f7a2d280a23ddf9eb7fdcb94a69f + languageName: node + linkType: hard + "cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" @@ -4380,6 +4647,13 @@ __metadata: languageName: node linkType: hard +"regenerator-runtime@npm:^0.14.0": + version: 0.14.1 + resolution: "regenerator-runtime@npm:0.14.1" + checksum: 9f57c93277b5585d3c83b0cf76be47b473ae8c6d9142a46ce8b0291a04bb2cf902059f0f8445dcabb3fb7378e5fe4bb4ea1e008876343d42e46d3b484534ce38 + languageName: node + linkType: hard + "regex-recursion@npm:^5.1.1": version: 5.1.1 resolution: "regex-recursion@npm:5.1.1" @@ -4971,6 +5245,13 @@ __metadata: languageName: node linkType: hard +"style-mod@npm:^4.0.0, style-mod@npm:^4.1.0": + version: 4.1.2 + resolution: "style-mod@npm:4.1.2" + checksum: 7c5c3e82747f9bcf5f288d8d07f50848e4630fe5ff7bfe4d94cc87d6b6a2588227cbf21b4c792ac6406e5852293300a75e710714479a5c59a06af677f0825ef8 + languageName: node + linkType: hard + "style-to-object@npm:^1.0.0": version: 1.0.8 resolution: "style-to-object@npm:1.0.8" @@ -5331,6 +5612,13 @@ __metadata: languageName: node linkType: hard +"w3c-keyname@npm:^2.2.4": + version: 2.2.8 + resolution: "w3c-keyname@npm:2.2.8" + checksum: 95bafa4c04fa2f685a86ca1000069c1ec43ace1f8776c10f226a73296caeddd83f893db885c2c220ebeb6c52d424e3b54d7c0c1e963bbf204038ff1a944fbb07 + languageName: node + linkType: hard + "which-boxed-primitive@npm:^1.1.0, which-boxed-primitive@npm:^1.1.1": version: 1.1.1 resolution: "which-boxed-primitive@npm:1.1.1" diff --git a/docs/tooling/cli/02-build-system.md b/docs/tooling/cli/02-build-system.md index 468e848694..8f9b086c33 100644 --- a/docs/tooling/cli/02-build-system.md +++ b/docs/tooling/cli/02-build-system.md @@ -483,29 +483,40 @@ of the build system, including the bundling, tests, builds, and type checking. Loaders are always selected based on the file extension. The following is a list of all supported file extensions: -| Extension | Exports | Purpose | -| --------- | ------------- | ------------------ | -| `.ts` | Script Module | TypeScript | -| `.tsx` | Script Module | TypeScript and XML | -| `.js` | Script Module | JavaScript | -| `.jsx` | Script Module | JavaScript and XML | -| `.mjs` | Script Module | ECMAScript Module | -| `.cjs` | Script Module | CommonJS Module | -| `.json` | JSON Data | JSON Data | -| `.yml` | JSON Data | YAML Data | -| `.yaml` | JSON Data | YAML Data | -| `.css` | classes | Style sheet | -| `.eot` | URL Path | Font | -| `.ttf` | URL Path | Font | -| `.woff2` | URL Path | Font | -| `.woff` | URL Path | Font | -| `.bmp` | URL Path | Image | -| `.gif` | URL Path | Image | -| `.jpeg` | URL Path | Image | -| `.jpg` | URL Path | Image | -| `.png` | URL Path | Image | -| `.svg` | URL Path | Image | -| `.md` | URL Path | Markdown File | +| Extension | Exports | Purpose | +| --------- | ------------- | ---------------------------- | +| `.ts` | Script Module | TypeScript | +| `.tsx` | Script Module | TypeScript and XML | +| `.mts` | Script Module | ECMAScript Module TypeScript | +| `.cts` | Script Module | CommonJS TypeScript | +| `.js` | Script Module | JavaScript | +| `.jsx` | Script Module | JavaScript and XML | +| `.mjs` | Script Module | ECMAScript Module | +| `.cjs` | Script Module | CommonJS Module | +| `.json` | JSON Data | JSON Data | +| `.yml` | JSON Data | YAML Data | +| `.yaml` | JSON Data | YAML Data | +| `.css` | classes | Style sheet | +| `.eot` | URL Path | Font | +| `.ttf` | URL Path | Font | +| `.woff2` | URL Path | Font | +| `.woff` | URL Path | Font | +| `.bmp` | URL Path | Image | +| `.gif` | URL Path | Image | +| `.jpeg` | URL Path | Image | +| `.jpg` | URL Path | Image | +| `.png` | URL Path | Image | +| `.svg` | URL Path | Image | +| `.md` | URL Path | Markdown File | + +## ECMAScript Modules + +The Backstage tooling supports [ECMAScript modules (ESM)](https://nodejs.org/docs/latest-v22.x/api/esm.html) in Node.js packages. This includes support for all the script module file extensions listed above during local development, in built packages, in tests, and during type checking. [Dynamic imports](https://nodejs.org/docs/latest-v22.x/api/esm.html#import-expressions) can be used to load ESM-only packages from CommonJS and vice versa. There are however a couple of limitations to be aware of: + +- To enable support for native ESM in tests, you need to run the tests with the `--experimental-vm-modules` flag enabled, typically via `NODE_OPTIONS='--experimental-vm-modules'`. +- Declaring a package as `"type": "module"` in `package.json` is supported, but in tests it will cause all local transitive dependencies to also be treated as ESM, regardless of whether they declare `"type": "module"` or not. +- Node.js has an [ESM interoperability layer with CommonJS](https://nodejs.org/docs/latest-v22.x/api/esm.html#interoperability-with-commonjs) that allows for imports from ESM to identify named exports in CommonJS packages. This interoperability layer is **only** enabled when importing packages with a `.cts` or `.cjs` extension. This is because the interoperability layer is not fully compatible with the NPM ecosystem, and would break package if it was enabled for `.js` files. +- Dynamic imports of CommonJS packages will vary in shape depending on the runtime, i.e. test vs local development, etc. It is therefore recommended to avoid dynamic imports of CommonJS packages and instead use `require`, or to use the explicit CommonJS extensions as mentioned above. If you do need to dynamically import CommonJS packages, avoid using `default` exports, as the shape of them vary across different environments and you would otherwise need to manually unwrap the import based on the shape of the module object. ## Jest Configuration diff --git a/package.json b/package.json index f4b44fdf52..41b8529c70 100644 --- a/package.json +++ b/package.json @@ -41,16 +41,16 @@ "snyk:test": "npx snyk test --yarn-workspaces --strict-out-of-sync=false", "snyk:test:package": "yarn snyk:test --include", "start": "yarn workspace example-app start", - "start-backend": "yarn workspace example-backend start", - "start-backend:legacy": "yarn workspace example-backend-legacy start", "start:lighthouse": "yarn workspaces foreach -A --include example-backend --include example-app --parallel --jobs unlimited -v -i run start", "start:microsite": "cd microsite/ && yarn start", "start:next": "yarn workspace example-app-next start", + "start-backend": "yarn workspace example-backend start", + "start-backend:legacy": "yarn workspace example-backend-legacy start", "storybook": "yarn ./storybook run storybook", "techdocs-cli": "node scripts/techdocs-cli.js", "techdocs-cli:dev": "cross-env TECHDOCS_CLI_DEV_MODE=true node scripts/techdocs-cli.js", - "test": "NODE_OPTIONS=--no-node-snapshot backstage-cli repo test", - "test:all": "NODE_OPTIONS=--no-node-snapshot backstage-cli repo test --coverage", + "test": "NODE_OPTIONS='--no-node-snapshot --experimental-vm-modules' backstage-cli repo test", + "test:all": "NODE_OPTIONS='--no-node-snapshot --experimental-vm-modules' backstage-cli repo test --coverage", "test:e2e": "NODE_OPTIONS=--no-node-snapshot playwright test", "tsc": "tsc", "tsc:full": "backstage-cli repo clean && tsc --skipLibCheck false --incremental false" diff --git a/packages/backend-defaults/src/entrypoints/database/connectors/postgres.ts b/packages/backend-defaults/src/entrypoints/database/connectors/postgres.ts index e7e973ae4b..3583049a7d 100644 --- a/packages/backend-defaults/src/entrypoints/database/connectors/postgres.ts +++ b/packages/backend-defaults/src/entrypoints/database/connectors/postgres.ts @@ -103,7 +103,7 @@ export async function buildPgDatabaseConfig( Connector: CloudSqlConnector, IpAddressTypes, AuthTypes, - } = await import('@google-cloud/cloud-sql-connector'); + } = require('@google-cloud/cloud-sql-connector') as typeof import('@google-cloud/cloud-sql-connector'); const connector = new CloudSqlConnector(); const clientOpts = await connector.getOptions({ instanceConnectionName: config.connection.instance, diff --git a/packages/backend-dynamic-feature-service/src/manager/plugin-manager.ts b/packages/backend-dynamic-feature-service/src/manager/plugin-manager.ts index c64fca053c..398e9fb14c 100644 --- a/packages/backend-dynamic-feature-service/src/manager/plugin-manager.ts +++ b/packages/backend-dynamic-feature-service/src/manager/plugin-manager.ts @@ -68,7 +68,7 @@ export class DynamicPluginManager implements DynamicPluginProvider { preferAlpha: options.preferAlpha, }); const scannedPlugins = (await scanner.scanRoot()).packages; - scanner.trackChanges(); + await scanner.trackChanges(); const moduleLoader = options.moduleLoader || new CommonJSModuleLoader({ logger: options.logger }); diff --git a/packages/backend-test-utils/src/cache/memcache.ts b/packages/backend-test-utils/src/cache/memcache.ts index b7ac07cb13..e985ee1c7d 100644 --- a/packages/backend-test-utils/src/cache/memcache.ts +++ b/packages/backend-test-utils/src/cache/memcache.ts @@ -59,7 +59,8 @@ export async function startMemcachedContainer( image: string, ): Promise { // Lazy-load to avoid side-effect of importing testcontainers - const { GenericContainer } = await import('testcontainers'); + const { GenericContainer } = + require('testcontainers') as typeof import('testcontainers'); const container = await new GenericContainer(image) .withExposedPorts(11211) diff --git a/packages/backend-test-utils/src/cache/redis.ts b/packages/backend-test-utils/src/cache/redis.ts index 6185e4d076..cd6e5c2893 100644 --- a/packages/backend-test-utils/src/cache/redis.ts +++ b/packages/backend-test-utils/src/cache/redis.ts @@ -57,7 +57,8 @@ export async function connectToExternalRedis( export async function startRedisContainer(image: string): Promise { // Lazy-load to avoid side-effect of importing testcontainers - const { GenericContainer } = await import('testcontainers'); + const { GenericContainer } = + require('testcontainers') as typeof import('testcontainers'); const container = await new GenericContainer(image) .withExposedPorts(6379) diff --git a/packages/backend-test-utils/src/database/mysql.ts b/packages/backend-test-utils/src/database/mysql.ts index b27edb7f9d..a7c11d8452 100644 --- a/packages/backend-test-utils/src/database/mysql.ts +++ b/packages/backend-test-utils/src/database/mysql.ts @@ -72,7 +72,8 @@ export async function startMysqlContainer(image: string): Promise<{ const password = uuid(); // Lazy-load to avoid side-effect of importing testcontainers - const { GenericContainer } = await import('testcontainers'); + const { GenericContainer } = + require('testcontainers') as typeof import('testcontainers'); const container = await new GenericContainer(image) .withExposedPorts(3306) diff --git a/packages/backend-test-utils/src/database/postgres.ts b/packages/backend-test-utils/src/database/postgres.ts index c8a946d121..e6122b9ab7 100644 --- a/packages/backend-test-utils/src/database/postgres.ts +++ b/packages/backend-test-utils/src/database/postgres.ts @@ -72,7 +72,8 @@ export async function startPostgresContainer(image: string): Promise<{ const password = uuid(); // Lazy-load to avoid side-effect of importing testcontainers - const { GenericContainer } = await import('testcontainers'); + const { GenericContainer } = + require('testcontainers') as typeof import('testcontainers'); const container = await new GenericContainer(image) .withExposedPorts(5432) diff --git a/packages/backend-test-utils/src/next/wiring/TestBackend.ts b/packages/backend-test-utils/src/next/wiring/TestBackend.ts index 0c46c2cd5f..c8190b734b 100644 --- a/packages/backend-test-utils/src/next/wiring/TestBackend.ts +++ b/packages/backend-test-utils/src/next/wiring/TestBackend.ts @@ -210,10 +210,19 @@ function isPromise(value: unknown | Promise): value is Promise { ); } +// Same as in the backend-app-api, handles double defaults from dynamic imports function unwrapFeature( - feature: BackendFeature | (() => BackendFeature), + feature: BackendFeature | { default: BackendFeature }, ): BackendFeature { - return typeof feature === 'function' ? feature() : feature; + if ('$$type' in feature) { + return feature; + } + + if ('default' in feature) { + return feature.default; + } + + return feature; } const backendInstancesToCleanUp = new Array(); diff --git a/packages/cli-node/report.api.md b/packages/cli-node/report.api.md index 7c1a3bc744..bec25ea251 100644 --- a/packages/cli-node/report.api.md +++ b/packages/cli-node/report.api.md @@ -72,6 +72,8 @@ export interface BackstagePackageJson { [key: string]: string; }; // (undocumented) + type?: 'module' | 'commonjs'; + // (undocumented) types?: string; // (undocumented) typesVersions?: Record>; diff --git a/packages/cli-node/src/monorepo/PackageGraph.ts b/packages/cli-node/src/monorepo/PackageGraph.ts index 721aa79e50..1da896a057 100644 --- a/packages/cli-node/src/monorepo/PackageGraph.ts +++ b/packages/cli-node/src/monorepo/PackageGraph.ts @@ -43,6 +43,8 @@ export interface BackstagePackageJson { // that the package bundles all of its dependencies in its build output. bundled?: boolean; + type?: 'module' | 'commonjs'; + backstage?: { role?: PackageRole; moved?: string; diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js index 45d744d7bc..560c9bf708 100644 --- a/packages/cli/config/jest.js +++ b/packages/cli/config/jest.js @@ -31,6 +31,14 @@ const FRONTEND_ROLES = [ 'frontend-plugin-module', ]; +const NODE_ROLES = [ + 'backend', + 'cli', + 'node-library', + 'backend-plugin', + 'backend-plugin-module', +]; + const envOptions = { oldTests: Boolean(process.env.BACKSTAGE_OLD_TESTS), }; @@ -130,11 +138,97 @@ const transformIgnorePattern = [ ].join('|'); // Provides additional config that's based on the role of the target package -function getRoleConfig(role) { +function getRoleConfig(role, pkgJson) { + // Only Node.js package roles support native ESM modules, frontend and common + // packages are always transpiled to CommonJS. + const moduleOpts = NODE_ROLES.includes(role) + ? { + module: { + ignoreDynamic: true, + exportInteropAnnotation: true, + }, + } + : undefined; + + const transform = { + '\\.(mjs|cjs|js)$': [ + require.resolve('./jestSwcTransform'), + { + ...moduleOpts, + jsc: { + parser: { + syntax: 'ecmascript', + }, + }, + }, + ], + '\\.jsx$': [ + require.resolve('./jestSwcTransform'), + { + jsc: { + parser: { + syntax: 'ecmascript', + jsx: true, + }, + transform: { + react: { + runtime: 'automatic', + }, + }, + }, + }, + ], + '\\.(mts|cts|ts)$': [ + require.resolve('./jestSwcTransform'), + { + ...moduleOpts, + jsc: { + parser: { + syntax: 'typescript', + }, + }, + }, + ], + '\\.tsx$': [ + require.resolve('./jestSwcTransform'), + { + jsc: { + parser: { + syntax: 'typescript', + tsx: true, + }, + transform: { + react: { + runtime: 'automatic', + }, + }, + }, + }, + ], + '\\.(bmp|gif|jpg|jpeg|png|ico|webp|frag|xml|svg|eot|woff|woff2|ttf)$': + require.resolve('./jestFileTransform.js'), + '\\.(yaml)$': require.resolve('./jestYamlTransform'), + }; if (FRONTEND_ROLES.includes(role)) { - return { testEnvironment: require.resolve('jest-environment-jsdom') }; + return { + testEnvironment: require.resolve('jest-environment-jsdom'), + transform, + }; } - return { testEnvironment: require.resolve('jest-environment-node') }; + return { + testEnvironment: require.resolve('jest-environment-node'), + moduleFileExtensions: [...SRC_EXTS, 'json', 'node'], + // Jest doesn't let us dynamically detect type=module per transformed file, + // so we have to assume that if the entry point is ESM, all TS files are + // ESM. + // + // This means you can't switch a package to type=module until all of its + // monorepo dependencies are also type=module or does not contain any .ts + // files. + extensionsToTreatAsEsm: + pkgJson.type === 'module' ? ['.ts', '.mts'] : ['.mts'], + transform, + }; } async function getProjectConfig(targetPath, extraConfig, extraOptions) { @@ -160,64 +254,6 @@ async function getProjectConfig(targetPath, extraConfig, extraOptions) { '\\.(css|less|scss|sss|styl)$': require.resolve('jest-css-modules'), }, - transform: { - '\\.(mjs|cjs|js)$': [ - require.resolve('./jestSwcTransform'), - { - jsc: { - parser: { - syntax: 'ecmascript', - }, - }, - }, - ], - '\\.jsx$': [ - require.resolve('./jestSwcTransform'), - { - jsc: { - parser: { - syntax: 'ecmascript', - jsx: true, - }, - transform: { - react: { - runtime: 'automatic', - }, - }, - }, - }, - ], - '\\.ts$': [ - require.resolve('./jestSwcTransform'), - { - jsc: { - parser: { - syntax: 'typescript', - }, - }, - }, - ], - '\\.tsx$': [ - require.resolve('./jestSwcTransform'), - { - jsc: { - parser: { - syntax: 'typescript', - tsx: true, - }, - transform: { - react: { - runtime: 'automatic', - }, - }, - }, - }, - ], - '\\.(bmp|gif|jpg|jpeg|png|ico|webp|frag|xml|svg|eot|woff|woff2|ttf)$': - require.resolve('./jestFileTransform.js'), - '\\.(yaml)$': require.resolve('./jestYamlTransform'), - }, - // A bit more opinionated testMatch: [`**/*.test.{${SRC_EXTS.join(',')}}`], @@ -226,7 +262,7 @@ async function getProjectConfig(targetPath, extraConfig, extraOptions) { : require.resolve('./jestCachingModuleLoader'), transformIgnorePatterns: [`/node_modules/(?:${transformIgnorePattern})/`], - ...getRoleConfig(pkgJson.backstage?.role), + ...getRoleConfig(pkgJson.backstage?.role, pkgJson), }; options.setupFilesAfterEnv = options.setupFilesAfterEnv || []; diff --git a/packages/cli/config/jestCachingModuleLoader.js b/packages/cli/config/jestCachingModuleLoader.js index 95c6212123..dd22f25f6f 100644 --- a/packages/cli/config/jestCachingModuleLoader.js +++ b/packages/cli/config/jestCachingModuleLoader.js @@ -19,6 +19,11 @@ const { default: JestRuntime } = require('jest-runtime'); const scriptTransformCache = new Map(); module.exports = class CachingJestRuntime extends JestRuntime { + constructor(config, ...restAgs) { + super(config, ...restAgs); + this.allowLoadAsEsm = config.extensionsToTreatAsEsm.includes('.mts'); + } + // This may or may not be a good idea. Theoretically I don't know why this would impact // test correctness and flakiness, but it seems like it may introduce flakiness and strange failures. // It does seem to speed up test execution by a fair amount though. @@ -33,4 +38,13 @@ module.exports = class CachingJestRuntime extends JestRuntime { } return script; } + + // Unfortunately we need to use this unstable API to make sure that .js files + // are only loaded as modules where ESM is supported, i.e. Node.js packages. + unstable_shouldLoadAsEsm(path, ...restArgs) { + if (!this.allowLoadAsEsm) { + return false; + } + return super.unstable_shouldLoadAsEsm(path, ...restArgs); + } }; diff --git a/packages/cli/config/jestSwcTransform.js b/packages/cli/config/jestSwcTransform.js index 83abacc9b5..a77683b939 100644 --- a/packages/cli/config/jestSwcTransform.js +++ b/packages/cli/config/jestSwcTransform.js @@ -23,10 +23,16 @@ function createTransformer(config) { ...config, }); const process = (source, filePath, jestOptions) => { + // Skip transformation of .js files without ESM syntax, we never transform from CJS to ESM if (filePath.endsWith('.js') && !ESM_REGEX.test(source)) { return { code: source }; } + // Skip transformation of .mjs files, they should only be used if ESM support is available + if (filePath.endsWith('.mjs')) { + return { code: source }; + } + return swcTransformer.process(source, filePath, jestOptions); }; diff --git a/packages/cli/config/nodeTransform.cjs b/packages/cli/config/nodeTransform.cjs index 1cf79cb1bb..f54527b4a7 100644 --- a/packages/cli/config/nodeTransform.cjs +++ b/packages/cli/config/nodeTransform.cjs @@ -14,6 +14,7 @@ * limitations under the License. */ +const { pathToFileURL } = require('url'); const { transformSync } = require('@swc/core'); const { addHook } = require('pirates'); const { Module } = require('module'); @@ -55,7 +56,10 @@ addHook( const transformed = transformSync(code, { filename, sourceMaps: 'inline', - module: { type: 'commonjs' }, + module: { + type: 'commonjs', + ignoreDynamic: true, + }, jsc: { target: 'es2022', parser: { @@ -76,3 +80,8 @@ addHook( }, { extensions: ['.js', '.cjs'], ignoreNodeModules: true }, ); + +// Register module hooks, used by "type": "module" in package.json, .mjs and +// .mts files, as well as dynamic import(...)s, although dynamic imports will be +// handled be the CommonJS hooks in this file if what it points to is CommonJS. +Module.register('./nodeTransformHooks.mjs', pathToFileURL(__filename)); diff --git a/packages/cli/config/nodeTransformHooks.mjs b/packages/cli/config/nodeTransformHooks.mjs new file mode 100644 index 0000000000..5d37ac05c0 --- /dev/null +++ b/packages/cli/config/nodeTransformHooks.mjs @@ -0,0 +1,282 @@ +/* + * Copyright 2024 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 { dirname, extname, resolve as resolvePath } from 'path'; +import { fileURLToPath } from 'url'; +import { transformFile } from '@swc/core'; +import { isBuiltin } from 'node:module'; +import { readFile } from 'fs/promises'; +import { existsSync } from 'fs'; + +// @ts-check + +// No explicit file extension, no type in package.json +const DEFAULT_MODULE_FORMAT = 'commonjs'; + +// Source file extensions to look for when using bundle resolution strategy +const SRC_EXTS = ['.ts', '.js']; +const TS_EXTS = ['.ts', '.mts', '.cts']; +const moduleTypeTable = { + '.mjs': 'module', + '.mts': 'module', + '.cjs': 'commonjs', + '.cts': 'commonjs', + '.ts': undefined, + '.js': undefined, +}; + +/** @type {import('module').ResolveHook} */ +export async function resolve(specifier, context, nextResolve) { + // Built-in modules are handled by the default resolver + if (isBuiltin(specifier)) { + return nextResolve(specifier, context); + } + + const ext = extname(specifier); + + // Unless there's an explicit import attribute, JSON files are loaded with our custom loader that's defined below. + if (ext === '.json' && !context.importAttributes?.type) { + const jsonResult = await nextResolve(specifier, context); + return { + ...jsonResult, + format: 'commonjs', + importAttributes: { type: 'json' }, + }; + } + + // Anything else with an explicit extension is handled by the default + // resolver, except that we help determine the module type where needed. + if (ext !== '') { + return withDetectedModuleType(await nextResolve(specifier, context)); + } + + // Other external modules are handled by the default resolver, but again we + // help determine the module type where needed. + if (!specifier.startsWith('.')) { + return withDetectedModuleType(await nextResolve(specifier, context)); + } + + // The rest of this function handles the case of resolving imports that do not + // specify any extension and might point to a directory with an `index.*` + // file. We resolve those using the same logic as most JS bundlers would, with + // the addition of checking if there's an explicit module format listed in the + // closest `package.json` file. + // + // We use a bundle resolution strategy in order to keep code consistent across + // Backstage codebases that contains code both for Web and Node.js, and to + // support packages with common code that can be used in both environments. + try { + // This is expected to throw, but in the event that this module specifier is + // supported we prefer to use the default resolver. + return await nextResolve(specifier, context); + } catch (error) { + if (error.code === 'ERR_UNSUPPORTED_DIR_IMPORT') { + const spec = `${specifier}${specifier.endsWith('/') ? '' : '/'}index`; + const resolved = await resolveWithoutExt(spec, context, nextResolve); + if (resolved) { + return withDetectedModuleType(resolved); + } + } else if (error.code === 'ERR_MODULE_NOT_FOUND') { + const resolved = await resolveWithoutExt(specifier, context, nextResolve); + if (resolved) { + return withDetectedModuleType(resolved); + } + } + + // Unexpected error or no resolution found + throw error; + } +} + +/** + * Populates the `format` field in the resolved object based on the closest `package.json` file. + * + * @param {import('module').ResolveFnOutput} resolved + * @returns {Promise} + */ +async function withDetectedModuleType(resolved) { + // Already has an explicit format + if (resolved.format) { + return resolved; + } + // Happens in Node.js v22 when there's a package.json without an explicit "type" field. Use the default. + if (resolved.format === null) { + return { ...resolved, format: DEFAULT_MODULE_FORMAT }; + } + + const ext = extname(resolved.url); + + const explicitFormat = moduleTypeTable[ext]; + if (explicitFormat) { + return { + ...resolved, + format: explicitFormat, + }; + } + + // TODO(Rugvip): Afaik this should never happen and we can remove this check, but want it here for a little while to verify. + if (ext === '.js') { + throw new Error('Unexpected .js file without explicit format'); + } + + // TODO(Rugvip): Does this need caching? kept it simple for now but worth exploring + const packageJsonPath = await findPackageJSON(fileURLToPath(resolved.url)); + if (!packageJsonPath) { + return resolved; + } + + const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')); + return { + ...resolved, + format: packageJson.type ?? DEFAULT_MODULE_FORMAT, + }; +} + +/** + * Find the closest package.json file from the given path. + * + * TODO(Rugvip): This can be replaced with the Node.js built-in with the same name once it is stable. + * @param {string} startPath + * @returns {Promise} + */ +async function findPackageJSON(startPath) { + let path = startPath; + + // Some confidence check to avoid infinite loop + for (let i = 0; i < 1000; i++) { + const packagePath = resolvePath(path, 'package.json'); + if (existsSync(packagePath)) { + return packagePath; + } + + const newPath = dirname(path); + if (newPath === path) { + return undefined; + } + path = newPath; + } + + throw new Error( + `Iteration limit reached when searching for package.json at ${startPath}`, + ); +} + +/** @type {import('module').ResolveHook} */ +async function resolveWithoutExt(specifier, context, nextResolve) { + for (const tryExt of SRC_EXTS) { + try { + const resolved = await nextResolve(specifier + tryExt, { + ...context, + format: 'commonjs', + }); + return { + ...resolved, + format: moduleTypeTable[tryExt] ?? resolved.format, + }; + } catch { + /* ignore */ + } + } + return undefined; +} + +/** @type {import('module').LoadHook} */ +export async function load(url, context, nextLoad) { + // Non-file URLs are handled by the default loader + if (!url.startsWith('file://')) { + return nextLoad(url, context); + } + + // JSON files loaded as CommonJS are handled by this custom loader, because + // the default one doesn't work. For JSON loading to work we'd need the + // synchronous hooks that aren't supported yet, or avoid using the CommonJS + // compatibility. + if ( + context.format === 'commonjs' && + context.importAttributes?.type === 'json' + ) { + try { + // TODO(Rugvip): Make sure this is valid JSON + const content = await readFile(fileURLToPath(url), 'utf8'); + return { + source: `module.exports = (${content})`, + format: 'commonjs', + shortCircuit: true, + }; + } catch { + // Let the default loader generate the error + return nextLoad(url, context); + } + } + + const ext = extname(url); + + // Non-TS files are handled by the default loader + if (!TS_EXTS.includes(ext)) { + return nextLoad(url, context); + } + + const format = context.format ?? DEFAULT_MODULE_FORMAT; + + // We have two choices at this point, we can either transform CommonJS files + // and return the transformed source code, or let the default loader handle + // them. If we transform them ourselves we will enter CommonJS compatibility + // mode in the new module system in Node.js, this effectively means all + // CommonJS loaded via `require` calls from this point will all be treated as + // if it was loaded via `import` calls from modules. + // + // The CommonJS compatibility layer will try to identify named exports and + // make them available directly, which is convenient as it avoids things like + // `import(...).then(m => m.default.foo)`, allowing you to instead write + // `import(...).then(m => m.foo)`. The compatibility layer doesn't always work + // all that well though, and can lead to module loading issues in many cases, + // especially for older code. + + // This `if` block opts-out of using CommonJS compatibility mode by default, + // and instead leaves it to our existing loader to transform CommonJS. We do + // however use compatibility mode for the more explicit .cts file extension, + // allows for a way to opt-in to the new behavior. + // + // TODO(Rugvip): Once the synchronous hooks API is available for us to use, we might be able to adopt that instead + if (format === 'commonjs' && ext !== '.cts') { + return nextLoad(url, { ...context, format }); + } + + const transformed = await transformFile(fileURLToPath(url), { + sourceMaps: 'inline', + module: { + type: format === 'module' ? 'es6' : 'commonjs', + ignoreDynamic: true, + + // This helps the Node.js CommonJS compat layer identify named exports. + exportInteropAnnotation: true, + }, + jsc: { + target: 'es2022', + parser: { + syntax: 'typescript', + }, + }, + }); + + return { + ...context, + shortCircuit: true, + source: transformed.code, + format, + responseURL: url, + }; +} diff --git a/packages/cli/config/tsconfig.json b/packages/cli/config/tsconfig.json index aa25e59451..ac5e62b52e 100644 --- a/packages/cli/config/tsconfig.json +++ b/packages/cli/config/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "allowImportingTsExtensions": true, "allowJs": true, "declaration": true, "declarationMap": false, diff --git a/packages/cli/src/alpha.ts b/packages/cli/src/alpha.ts index 4b2cfa4842..f6cd2157ae 100644 --- a/packages/cli/src/alpha.ts +++ b/packages/cli/src/alpha.ts @@ -24,6 +24,6 @@ import chalk from 'chalk'; ), ); const initializer = new CliInitializer(); - initializer.add(import('./modules/config/alpha').then(m => m.default)); + initializer.add(import('./modules/config/alpha')); await initializer.run(); })(); diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 0f38fc1e8a..f1119053ec 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -43,7 +43,7 @@ export function registerRepoCommand(program: Command) { '--minify', 'Minify the generated code. Does not apply to app package (app is minified by default).', ) - .action(lazy(() => import('./repo/build').then(m => m.command))); + .action(lazy(() => import('./repo/build'), 'command')); command .command('lint') @@ -70,7 +70,7 @@ export function registerRepoCommand(program: Command) { 'Set the success cache location, (default: node_modules/.cache/backstage-cli)', ) .option('--fix', 'Attempt to automatically fix violations') - .action(lazy(() => import('./repo/lint').then(m => m.command))); + .action(lazy(() => import('./repo/lint'), 'command')); command .command('fix') @@ -83,20 +83,18 @@ export function registerRepoCommand(program: Command) { '--check', 'Fail if any packages would have been changed by the command', ) - .action(lazy(() => import('./repo/fix').then(m => m.command))); + .action(lazy(() => import('./repo/fix'), 'command')); command .command('clean') .description('Delete cache and output directories') - .action(lazy(() => import('./repo/clean').then(m => m.command))); + .action(lazy(() => import('./repo/clean'), 'command')); command .command('list-deprecations') .description('List deprecations') .option('--json', 'Output as JSON') - .action( - lazy(() => import('./repo/list-deprecations').then(m => m.command)), - ); + .action(lazy(() => import('./repo/list-deprecations'), 'command')); command .command('test') @@ -118,7 +116,7 @@ export function registerRepoCommand(program: Command) { 'Show help for Jest CLI options, which are passed through', ) .description('Run tests, forwarding args to Jest, defaulting to watch mode') - .action(lazy(() => import('./repo/test').then(m => m.command))); + .action(lazy(() => import('./repo/test'), 'command')); } export function registerScriptCommand(program: Command) { @@ -139,7 +137,7 @@ export function registerScriptCommand(program: Command) { ) .option('--require ', 'Add a --require argument to the node process') .option('--link ', 'Link an external workspace for module resolution') - .action(lazy(() => import('./start').then(m => m.command))); + .action(lazy(() => import('./start'), 'command')); command .command('build') @@ -163,7 +161,7 @@ export function registerScriptCommand(program: Command) { (opt: string, opts: string[]) => (opts ? [...opts, opt] : [opt]), Array(), ) - .action(lazy(() => import('./build').then(m => m.command))); + .action(lazy(() => import('./build'), 'command')); command .command('lint [directories...]') @@ -182,29 +180,29 @@ export function registerScriptCommand(program: Command) { 'Fail if more than this number of warnings. -1 allows warnings. (default: 0)', ) .description('Lint a package') - .action(lazy(() => import('./lint').then(m => m.default))); + .action(lazy(() => import('./lint'), 'default')); command .command('test') .allowUnknownOption(true) // Allows the command to run, but we still need to parse raw args .helpOption(', --backstage-cli-help') // Let Jest handle help .description('Run tests, forwarding args to Jest, defaulting to watch mode') - .action(lazy(() => import('./test').then(m => m.default))); + .action(lazy(() => import('./test'), 'default')); command .command('clean') .description('Delete cache directories') - .action(lazy(() => import('./clean/clean').then(m => m.default))); + .action(lazy(() => import('./clean/clean'), 'default')); command .command('prepack') .description('Prepares a package for packaging before publishing') - .action(lazy(() => import('./pack').then(m => m.pre))); + .action(lazy(() => import('./pack'), 'pre')); command .command('postpack') .description('Restores the changes made by the prepack command') - .action(lazy(() => import('./pack').then(m => m.post))); + .action(lazy(() => import('./pack'), 'post')); } export function registerMigrateCommand(program: Command) { @@ -215,39 +213,31 @@ export function registerMigrateCommand(program: Command) { command .command('package-roles') .description(`Add package role field to packages that don't have it`) - .action(lazy(() => import('./migrate/packageRole').then(m => m.default))); + .action(lazy(() => import('./migrate/packageRole'), 'default')); command .command('package-scripts') .description('Set package scripts according to each package role') - .action( - lazy(() => import('./migrate/packageScripts').then(m => m.command)), - ); + .action(lazy(() => import('./migrate/packageScripts'), 'command')); command .command('package-exports') .description('Synchronize package subpath export definitions') - .action( - lazy(() => import('./migrate/packageExports').then(m => m.command)), - ); + .action(lazy(() => import('./migrate/packageExports'), 'command')); command .command('package-lint-configs') .description( 'Migrates all packages to use @backstage/cli/config/eslint-factory', ) - .action( - lazy(() => import('./migrate/packageLintConfigs').then(m => m.command)), - ); + .action(lazy(() => import('./migrate/packageLintConfigs'), 'command')); command .command('react-router-deps') .description( 'Migrates the react-router dependencies for all packages to be peer dependencies', ) - .action( - lazy(() => import('./migrate/reactRouterDeps').then(m => m.command)), - ); + .action(lazy(() => import('./migrate/reactRouterDeps'), 'command')); } export function registerCommands(program: Command) { @@ -281,7 +271,7 @@ export function registerCommands(program: Command) { 'The license to use for any new packages (default: Apache-2.0)', ) .option('--no-private', 'Do not mark new packages as private') - .action(lazy(() => import('./new/new').then(m => m.default))); + .action(lazy(() => import('./new/new'), 'default')); registerConfigCommands(program); registerRepoCommand(program); @@ -302,7 +292,7 @@ export function registerCommands(program: Command) { .option('--skip-install', 'Skips yarn install step') .option('--skip-migrate', 'Skips migration of any moved packages') .description('Bump Backstage packages to the latest versions') - .action(lazy(() => import('./versions/bump').then(m => m.default))); + .action(lazy(() => import('./versions/bump'), 'default')); program .command('versions:migrate') @@ -317,7 +307,7 @@ export function registerCommands(program: Command) { .description( 'Migrate any plugins that have been moved to the @backstage-community namespace automatically', ) - .action(lazy(() => import('./versions/migrate').then(m => m.default))); + .action(lazy(() => import('./versions/migrate'), 'default')); program .command('build-workspace [packages...]') @@ -334,17 +324,17 @@ export function registerCommands(program: Command) { 'Force workspace output to be a result of running `yarn pack` on each package (warning: very slow)', ) .description('Builds a temporary dist workspace from the provided packages') - .action(lazy(() => import('./buildWorkspace').then(m => m.default))); + .action(lazy(() => import('./buildWorkspace'), 'default')); program .command('create-github-app ') .description('Create new GitHub App in your organization.') - .action(lazy(() => import('./create-github-app').then(m => m.default))); + .action(lazy(() => import('./create-github-app'), 'default')); program .command('info') .description('Show helpful information for debugging and reporting bugs') - .action(lazy(() => import('./info').then(m => m.default))); + .action(lazy(() => import('./info'), 'default')); // Notifications for removed commands program diff --git a/packages/cli/src/lib/builder/config.ts b/packages/cli/src/lib/builder/config.ts index e419503649..3b55a5a451 100644 --- a/packages/cli/src/lib/builder/config.ts +++ b/packages/cli/src/lib/builder/config.ts @@ -16,7 +16,11 @@ import chalk from 'chalk'; import fs from 'fs-extra'; -import { relative as relativePath, resolve as resolvePath } from 'path'; +import { + extname, + relative as relativePath, + resolve as resolvePath, +} from 'path'; import commonjs from '@rollup/plugin-commonjs'; import resolve from '@rollup/plugin-node-resolve'; import postcss from 'rollup-plugin-postcss'; @@ -29,6 +33,7 @@ import { RollupOptions, OutputOptions, WarningHandlerWithDefault, + OutputPlugin, } from 'rollup'; import { forwardFileImports } from './plugins'; @@ -40,6 +45,12 @@ import { readEntryPoints } from '../entryPoints'; const SCRIPT_EXTS = ['.js', '.jsx', '.ts', '.tsx']; +const MODULE_EXTS = ['.mjs', '.mts']; +const COMMONJS_EXTS = ['.cjs', '.cts']; +const MOD_EXT = '.mjs'; +const CJS_EXT = '.cjs'; +const CJS_JS_EXT = '.cjs.js'; + function isFileImport(source: string) { if (source.startsWith('.')) { return true; @@ -68,6 +79,39 @@ function buildInternalImportPattern(options: BuildOptions) { return new RegExp(`^(?:${names.join('|')})(?:$|/)`); } +// This Rollup output plugin enables support for mixed CommonJS and ESM output. +// It does it be filtering out the unwanted output files that don't match the +// input file format, allowing the rollup configuration to have overlapping +// output configurations for different formats. +function multiOutputFormat(): OutputPlugin { + return { + name: 'backstage-multi-output-format', + generateBundle(opts, bundle) { + const filter: (name: string) => boolean = + opts.format === 'cjs' + ? s => s.endsWith(MOD_EXT) + : s => !s.endsWith(MOD_EXT); + + // Delete any files that don't match the current output format + for (const name in bundle) { + if (filter(name)) { + delete bundle[name]; + delete bundle[`${name}.map`]; + } + } + }, + renderDynamicImport(opts) { + if (opts.format === 'cjs') { + return { + left: 'import(', + right: ')', + }; + } + return undefined; + }, + }; +} + export async function makeRollupConfigs( options: BuildOptions, ): Promise { @@ -120,18 +164,47 @@ export async function makeRollupConfigs( const rewriteNodeModules = (name: string) => name.replaceAll('node_modules', 'node_modules_dist'); + // For CommonJS we build both CommonJS and ESM output. Each of these outputs + // can output both .cjs and .mjs files. The files from each of these outputs + // will overlap, but we trim away files where the format doesn't match the + // file extensions. That way we are left with a combination of .cjs and .mjs + // files where the module format in the file matches the file extension. if (options.outputs.has(Output.cjs)) { - output.push({ + const defaultExt = targetPkg.type === 'module' ? MOD_EXT : CJS_JS_EXT; + const outputOpts: OutputOptions = { dir: distDir, - entryFileNames: chunkInfo => - `${rewriteNodeModules(chunkInfo.name)}.cjs.js`, - chunkFileNames: `cjs/[name]-[hash].cjs.js`, - format: 'commonjs', - interop: 'compat', + entryFileNames(chunkInfo) { + const cleanName = rewriteNodeModules(chunkInfo.name); + + const inputId = chunkInfo.facadeModuleId; + if (!inputId) { + return cleanName + defaultExt; + } + + const inputExt = extname(inputId); + if (MODULE_EXTS.includes(inputExt)) { + return cleanName + MOD_EXT; + } + if (COMMONJS_EXTS.includes(inputExt)) { + return cleanName + CJS_EXT; + } + return cleanName + defaultExt; + }, sourcemap: true, preserveModules: true, preserveModulesRoot: `${targetDir}/src`, + interop: 'compat', exports: 'named', + plugins: [multiOutputFormat()], + }; + + output.push({ + ...outputOpts, + format: 'cjs', + }); + output.push({ + ...outputOpts, + format: 'module', }); } if (options.outputs.has(Output.esm)) { @@ -160,7 +233,10 @@ export async function makeRollupConfigs( // All module imports are always marked as external external, plugins: [ - resolve({ mainFields }), + resolve({ + mainFields, + extensions: SCRIPT_EXTS, + }), commonjs({ include: /node_modules/, exclude: [/\/[^/]+\.(?:stories|test)\.[^/]+$/], diff --git a/packages/cli/src/lib/builder/packager.ts b/packages/cli/src/lib/builder/packager.ts index be9bc9a6ea..b709cb0408 100644 --- a/packages/cli/src/lib/builder/packager.ts +++ b/packages/cli/src/lib/builder/packager.ts @@ -107,7 +107,7 @@ export const buildPackage = async (options: BuildOptions) => { const rollupConfigs = await makeRollupConfigs(options); - await fs.remove(paths.resolveTarget('dist')); + await fs.remove(resolvePath(options.targetDir ?? paths.targetDir, 'dist')); const buildTasks = rollupConfigs.map(rollupBuild); diff --git a/packages/cli/src/lib/lazy.ts b/packages/cli/src/lib/lazy.ts index 6d2cb1cd4f..d1255ea1bc 100644 --- a/packages/cli/src/lib/lazy.ts +++ b/packages/cli/src/lib/lazy.ts @@ -17,13 +17,25 @@ import { assertError } from '@backstage/errors'; import { exitWithError } from '../lib/errors'; +type ActionFunc = (...args: any[]) => Promise; +type ActionExports = { + [KName in keyof TModule as TModule[KName] extends ActionFunc + ? KName + : never]: TModule[KName]; +}; + // Wraps an action function so that it always exits and handles errors -export function lazy( - getActionFunc: () => Promise<(...args: any[]) => Promise>, +export function lazy( + moduleLoader: () => Promise, + exportName: keyof ActionExports, ): (...args: any[]) => Promise { return async (...args: any[]) => { try { - const actionFunc = await getActionFunc(); + const mod = await moduleLoader(); + const actualModule = ( + mod as unknown as { default: ActionExports } + ).default; + const actionFunc = actualModule[exportName] as ActionFunc; await actionFunc(...args); process.exit(0); diff --git a/packages/cli/src/modules/config/alpha.ts b/packages/cli/src/modules/config/alpha.ts index c32982086a..9ce6080a4a 100644 --- a/packages/cli/src/modules/config/alpha.ts +++ b/packages/cli/src/modules/config/alpha.ts @@ -32,7 +32,7 @@ export default createCliPlugin({ 'Only include the schema that applies to the given package', ) .description('Browse the configuration reference documentation') - .action(lazy(() => import('./commands/docs').then(m => m.default))); + .action(lazy(() => import('./commands/docs'), 'default')); await defaultCommand.parseAsync(args, { from: 'user' }); }, diff --git a/packages/cli/src/modules/config/index.ts b/packages/cli/src/modules/config/index.ts index 0c6a79577d..a1436d0c21 100644 --- a/packages/cli/src/modules/config/index.ts +++ b/packages/cli/src/modules/config/index.ts @@ -32,7 +32,7 @@ export function registerCommands(program: Command) { 'Only include the schema that applies to the given package', ) .description('Browse the configuration reference documentation') - .action(lazy(() => import('./commands/docs').then(m => m.default))); + .action(lazy(() => import('./commands/docs'), 'default')); program .command('config:print') @@ -49,7 +49,7 @@ export function registerCommands(program: Command) { ) .option(...configOption) .description('Print the app configuration for the current package') - .action(lazy(() => import('./commands/print').then(m => m.default))); + .action(lazy(() => import('./commands/print'), 'default')); program .command('config:check') @@ -68,7 +68,7 @@ export function registerCommands(program: Command) { .description( 'Validate that the given configuration loads and matches schema', ) - .action(lazy(() => import('./commands/validate').then(m => m.default))); + .action(lazy(() => import('./commands/validate'), 'default')); program .command('config:schema') @@ -83,5 +83,5 @@ export function registerCommands(program: Command) { .option('--merge', 'Print the config schemas merged', true) .option('--no-merge', 'Print the config schemas not merged') .description('Print configuration schema') - .action(lazy(() => import('./commands/schema').then(m => m.default))); + .action(lazy(() => import('./commands/schema'), 'default')); } diff --git a/packages/cli/src/tests/transforms/__fixtures__/.gitignore b/packages/cli/src/tests/transforms/__fixtures__/.gitignore new file mode 100644 index 0000000000..dd13a98e05 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/.gitignore @@ -0,0 +1,2 @@ +!node_modules +dist diff --git a/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/a-default.js b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/a-default.js new file mode 100644 index 0000000000..67606e3f86 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/a-default.js @@ -0,0 +1 @@ +module.exports = 'a' diff --git a/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/a-named.js b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/a-named.js new file mode 100644 index 0000000000..0710f9dbe6 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/a-named.js @@ -0,0 +1 @@ +exports.value = 'a' diff --git a/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/b-default.mjs b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/b-default.mjs new file mode 100644 index 0000000000..a3bb49043e --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/b-default.mjs @@ -0,0 +1 @@ +export default 'b' diff --git a/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/b-named.mjs b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/b-named.mjs new file mode 100644 index 0000000000..18049c8488 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/b-named.mjs @@ -0,0 +1 @@ +export const value = 'b' diff --git a/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/c-default.cjs b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/c-default.cjs new file mode 100644 index 0000000000..7212f4d5a7 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/c-default.cjs @@ -0,0 +1 @@ +module.exports = 'c' diff --git a/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/c-named.cjs b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/c-named.cjs new file mode 100644 index 0000000000..c1dcb4b923 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/c-named.cjs @@ -0,0 +1 @@ +exports.value = 'c' diff --git a/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/main.d.ts b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/main.d.ts new file mode 100644 index 0000000000..bdc1e24a02 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/main.d.ts @@ -0,0 +1,15 @@ +export const namedA: string +export const namedB: string +export const namedC: string +export const defaultA: string +export const defaultB: string +export const defaultC: string + +export namespace dyn { + export const namedA: Promise + export const namedB: Promise + export const namedC: Promise + export const defaultA: Promise + export const defaultB: Promise + export const defaultC: Promise +} diff --git a/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/main.js b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/main.js new file mode 100644 index 0000000000..d59f7789bd --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/main.js @@ -0,0 +1,14 @@ +exports.namedA = require('./a-named').value; +// exports.namedB = require('./b-named.mjs').value; +exports.namedC = require('./c-named.cjs').value; +exports.defaultA = require('./a-default'); +// exports.defaultB = require('./b-default.mjs').default; +exports.defaultC = require('./c-default.cjs'); +exports.dyn = { + namedA: import('./a-named').then(m => m.value), + namedB: import('./b-named.mjs').then(m => m.value), + namedC: import('./c-named.cjs').then(m => m.value), + defaultA: import('./a-default').then(m => m.default), + defaultB: import('./b-default.mjs').then(m => m.default), + defaultC: import('./c-default.cjs').then(m => m.default), +} diff --git a/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/package.json b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/package.json new file mode 100644 index 0000000000..447f95f566 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/package.json @@ -0,0 +1,14 @@ +{ + "name": "dep-commonjs", + "type": "commonjs", + "exports": { + ".": "./main.js" + }, + "typesVersions": { + "*": { + "*": [ + "main.d.ts" + ] + } + } +} diff --git a/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-default/a-default.js b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-default/a-default.js new file mode 100644 index 0000000000..67606e3f86 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-default/a-default.js @@ -0,0 +1 @@ +module.exports = 'a' diff --git a/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-default/a-named.js b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-default/a-named.js new file mode 100644 index 0000000000..0710f9dbe6 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-default/a-named.js @@ -0,0 +1 @@ +exports.value = 'a' diff --git a/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-default/b-default.mjs b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-default/b-default.mjs new file mode 100644 index 0000000000..a3bb49043e --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-default/b-default.mjs @@ -0,0 +1 @@ +export default 'b' diff --git a/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-default/b-named.mjs b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-default/b-named.mjs new file mode 100644 index 0000000000..18049c8488 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-default/b-named.mjs @@ -0,0 +1 @@ +export const value = 'b' diff --git a/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-default/c-default.cjs b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-default/c-default.cjs new file mode 100644 index 0000000000..7212f4d5a7 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-default/c-default.cjs @@ -0,0 +1 @@ +module.exports = 'c' diff --git a/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-default/c-named.cjs b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-default/c-named.cjs new file mode 100644 index 0000000000..c1dcb4b923 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-default/c-named.cjs @@ -0,0 +1 @@ +exports.value = 'c' diff --git a/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-default/main.d.ts b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-default/main.d.ts new file mode 100644 index 0000000000..bdc1e24a02 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-default/main.d.ts @@ -0,0 +1,15 @@ +export const namedA: string +export const namedB: string +export const namedC: string +export const defaultA: string +export const defaultB: string +export const defaultC: string + +export namespace dyn { + export const namedA: Promise + export const namedB: Promise + export const namedC: Promise + export const defaultA: Promise + export const defaultB: Promise + export const defaultC: Promise +} diff --git a/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-default/main.js b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-default/main.js new file mode 100644 index 0000000000..d59f7789bd --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-default/main.js @@ -0,0 +1,14 @@ +exports.namedA = require('./a-named').value; +// exports.namedB = require('./b-named.mjs').value; +exports.namedC = require('./c-named.cjs').value; +exports.defaultA = require('./a-default'); +// exports.defaultB = require('./b-default.mjs').default; +exports.defaultC = require('./c-default.cjs'); +exports.dyn = { + namedA: import('./a-named').then(m => m.value), + namedB: import('./b-named.mjs').then(m => m.value), + namedC: import('./c-named.cjs').then(m => m.value), + defaultA: import('./a-default').then(m => m.default), + defaultB: import('./b-default.mjs').then(m => m.default), + defaultC: import('./c-default.cjs').then(m => m.default), +} diff --git a/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-default/package.json b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-default/package.json new file mode 100644 index 0000000000..e3ceaa6fd7 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-default/package.json @@ -0,0 +1,13 @@ +{ + "name": "dep-default", + "exports": { + ".": "./main.js" + }, + "typesVersions": { + "*": { + "*": [ + "main.d.ts" + ] + } + } +} diff --git a/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-module/a-default.js b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-module/a-default.js new file mode 100644 index 0000000000..90bd54cd7f --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-module/a-default.js @@ -0,0 +1 @@ +export default 'a' diff --git a/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-module/a-named.js b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-module/a-named.js new file mode 100644 index 0000000000..7fea2538a3 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-module/a-named.js @@ -0,0 +1 @@ +export const value = 'a' diff --git a/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-module/b-default.mjs b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-module/b-default.mjs new file mode 100644 index 0000000000..a3bb49043e --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-module/b-default.mjs @@ -0,0 +1 @@ +export default 'b' diff --git a/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-module/b-named.mjs b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-module/b-named.mjs new file mode 100644 index 0000000000..18049c8488 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-module/b-named.mjs @@ -0,0 +1 @@ +export const value = 'b' diff --git a/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-module/c-default.cjs b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-module/c-default.cjs new file mode 100644 index 0000000000..7212f4d5a7 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-module/c-default.cjs @@ -0,0 +1 @@ +module.exports = 'c' diff --git a/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-module/c-named.cjs b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-module/c-named.cjs new file mode 100644 index 0000000000..c1dcb4b923 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-module/c-named.cjs @@ -0,0 +1 @@ +exports.value = 'c' diff --git a/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-module/main.d.ts b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-module/main.d.ts new file mode 100644 index 0000000000..bdc1e24a02 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-module/main.d.ts @@ -0,0 +1,15 @@ +export const namedA: string +export const namedB: string +export const namedC: string +export const defaultA: string +export const defaultB: string +export const defaultC: string + +export namespace dyn { + export const namedA: Promise + export const namedB: Promise + export const namedC: Promise + export const defaultA: Promise + export const defaultB: Promise + export const defaultC: Promise +} diff --git a/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-module/main.js b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-module/main.js new file mode 100644 index 0000000000..8b42a09d4a --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-module/main.js @@ -0,0 +1,14 @@ +export { value as namedA } from './a-named' +export { value as namedB } from './b-named.mjs' +export { value as namedC } from './c-named.cjs' +export { default as defaultA } from './a-default' +export { default as defaultB } from './b-default.mjs' +export { default as defaultC } from './c-default.cjs' +export const dyn = { + namedA: import('./a-named').then(m => m.value), + namedB: import('./b-named.mjs').then(m => m.value), + namedC: import('./c-named.cjs').then(m => m.value), + defaultA: import('./a-default').then(m => m.default), + defaultB: import('./b-default.mjs').then(m => m.default), + defaultC: import('./c-default.cjs').then(m => m.default), +} diff --git a/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-module/package.json b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-module/package.json new file mode 100644 index 0000000000..17d9e50e5a --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/node_modules/dep-module/package.json @@ -0,0 +1,14 @@ +{ + "name": "dep-module", + "type": "module", + "exports": { + ".": "./main.js" + }, + "typesVersions": { + "*": { + "*": [ + "main.d.ts" + ] + } + } +} diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-commonjs/a-default.ts b/packages/cli/src/tests/transforms/__fixtures__/pkg-commonjs/a-default.ts new file mode 100644 index 0000000000..a3d64880a9 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-commonjs/a-default.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 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 default 'a'; diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-commonjs/a-named.ts b/packages/cli/src/tests/transforms/__fixtures__/pkg-commonjs/a-named.ts new file mode 100644 index 0000000000..132570f2bb --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-commonjs/a-named.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 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 const value = 'a'; diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-commonjs/b-default.mts b/packages/cli/src/tests/transforms/__fixtures__/pkg-commonjs/b-default.mts new file mode 100644 index 0000000000..57d07f2c30 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-commonjs/b-default.mts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 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 default 'b'; diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-commonjs/b-named.mts b/packages/cli/src/tests/transforms/__fixtures__/pkg-commonjs/b-named.mts new file mode 100644 index 0000000000..be9bb39348 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-commonjs/b-named.mts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 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 const value = 'b'; diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-commonjs/c-default.cts b/packages/cli/src/tests/transforms/__fixtures__/pkg-commonjs/c-default.cts new file mode 100644 index 0000000000..48f5b4f136 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-commonjs/c-default.cts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 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 default 'c'; diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-commonjs/c-named.cts b/packages/cli/src/tests/transforms/__fixtures__/pkg-commonjs/c-named.cts new file mode 100644 index 0000000000..c3e5b58572 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-commonjs/c-named.cts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 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 const value = 'c'; diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-commonjs/main.ts b/packages/cli/src/tests/transforms/__fixtures__/pkg-commonjs/main.ts new file mode 100644 index 0000000000..d6bdfb30d1 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-commonjs/main.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2024 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 * as depCommonJs from 'dep-commonjs'; +// import * as depModule from 'dep-module'; +import * as depDefault from 'dep-default'; +import { value as namedA } from './a-named'; +// import { value as namedB } from './b-named.mts'; +import { value as namedC } from './c-named.cts'; +import { default as defaultA } from './a-default'; +// import { default as defaultB } from './b-default.mts'; +import { default as defaultC } from './c-default.cts'; + +async function resolveAll(obj: object): Promise { + const val = await obj; + if (typeof val !== 'object' || val === null) { + return val; + } + if (Array.isArray(val)) { + return await Promise.all(val.map(resolveAll)); + } + return Object.fromEntries( + await Promise.all( + Object.entries(obj).map(async ([key, value]) => [ + key, + await resolveAll(await value), + ]), + ), + ); +} + +export const values = resolveAll({ + depCommonJs, + // depModule, + depDefault, + dynCommonJs: import('dep-commonjs'), + dynModule: import('dep-module'), + dynDefault: import('dep-default'), + dep: { + namedA, + // namedB, + namedC, + defaultA, + // defaultB, + defaultC, + }, + dyn: { + // @ts-expect-error Default exports from CommonJS are not well supported + namedA: import('./a-named').then(m => m.default.value), + namedB: import('./b-named.mts').then(m => m.value), + namedC: import('./c-named.cts').then(m => m.value), + // @ts-expect-error Default exports from CommonJS are not well supported + defaultA: import('./a-default').then(m => m.default.default), + defaultB: import('./b-default.mts').then(m => m.default), + // @ts-expect-error Default exports from CommonJS are not well supported + defaultC: import('./c-default.cts').then(m => m.default.default), + }, +}); diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-commonjs/package.json b/packages/cli/src/tests/transforms/__fixtures__/pkg-commonjs/package.json new file mode 100644 index 0000000000..b39408ee58 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-commonjs/package.json @@ -0,0 +1,8 @@ +{ + "name": "pkg-commonjs", + "type": "commonjs", + "exports": { + ".": "./main.ts", + "./print": "./print.ts" + } +} diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-commonjs/print.ts b/packages/cli/src/tests/transforms/__fixtures__/pkg-commonjs/print.ts new file mode 100644 index 0000000000..7c24f99f37 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-commonjs/print.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2024 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 { values } from './main'; + +values.then(obj => console.log(JSON.stringify(obj, null, 2))); diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-default/a-default.ts b/packages/cli/src/tests/transforms/__fixtures__/pkg-default/a-default.ts new file mode 100644 index 0000000000..a3d64880a9 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-default/a-default.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 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 default 'a'; diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-default/a-named.ts b/packages/cli/src/tests/transforms/__fixtures__/pkg-default/a-named.ts new file mode 100644 index 0000000000..132570f2bb --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-default/a-named.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 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 const value = 'a'; diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-default/b-default.mts b/packages/cli/src/tests/transforms/__fixtures__/pkg-default/b-default.mts new file mode 100644 index 0000000000..57d07f2c30 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-default/b-default.mts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 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 default 'b'; diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-default/b-named.mts b/packages/cli/src/tests/transforms/__fixtures__/pkg-default/b-named.mts new file mode 100644 index 0000000000..be9bb39348 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-default/b-named.mts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 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 const value = 'b'; diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-default/c-default.cts b/packages/cli/src/tests/transforms/__fixtures__/pkg-default/c-default.cts new file mode 100644 index 0000000000..48f5b4f136 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-default/c-default.cts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 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 default 'c'; diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-default/c-named.cts b/packages/cli/src/tests/transforms/__fixtures__/pkg-default/c-named.cts new file mode 100644 index 0000000000..c3e5b58572 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-default/c-named.cts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 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 const value = 'c'; diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-default/main.ts b/packages/cli/src/tests/transforms/__fixtures__/pkg-default/main.ts new file mode 100644 index 0000000000..d6bdfb30d1 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-default/main.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2024 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 * as depCommonJs from 'dep-commonjs'; +// import * as depModule from 'dep-module'; +import * as depDefault from 'dep-default'; +import { value as namedA } from './a-named'; +// import { value as namedB } from './b-named.mts'; +import { value as namedC } from './c-named.cts'; +import { default as defaultA } from './a-default'; +// import { default as defaultB } from './b-default.mts'; +import { default as defaultC } from './c-default.cts'; + +async function resolveAll(obj: object): Promise { + const val = await obj; + if (typeof val !== 'object' || val === null) { + return val; + } + if (Array.isArray(val)) { + return await Promise.all(val.map(resolveAll)); + } + return Object.fromEntries( + await Promise.all( + Object.entries(obj).map(async ([key, value]) => [ + key, + await resolveAll(await value), + ]), + ), + ); +} + +export const values = resolveAll({ + depCommonJs, + // depModule, + depDefault, + dynCommonJs: import('dep-commonjs'), + dynModule: import('dep-module'), + dynDefault: import('dep-default'), + dep: { + namedA, + // namedB, + namedC, + defaultA, + // defaultB, + defaultC, + }, + dyn: { + // @ts-expect-error Default exports from CommonJS are not well supported + namedA: import('./a-named').then(m => m.default.value), + namedB: import('./b-named.mts').then(m => m.value), + namedC: import('./c-named.cts').then(m => m.value), + // @ts-expect-error Default exports from CommonJS are not well supported + defaultA: import('./a-default').then(m => m.default.default), + defaultB: import('./b-default.mts').then(m => m.default), + // @ts-expect-error Default exports from CommonJS are not well supported + defaultC: import('./c-default.cts').then(m => m.default.default), + }, +}); diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-default/package.json b/packages/cli/src/tests/transforms/__fixtures__/pkg-default/package.json new file mode 100644 index 0000000000..5b0b075a3c --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-default/package.json @@ -0,0 +1,7 @@ +{ + "name": "pkg-default", + "exports": { + ".": "./main.ts", + "./print": "./print.ts" + } +} diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-default/print.ts b/packages/cli/src/tests/transforms/__fixtures__/pkg-default/print.ts new file mode 100644 index 0000000000..7c24f99f37 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-default/print.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2024 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 { values } from './main'; + +values.then(obj => console.log(JSON.stringify(obj, null, 2))); diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-module/a-default-explicit.mts b/packages/cli/src/tests/transforms/__fixtures__/pkg-module/a-default-explicit.mts new file mode 100644 index 0000000000..a3d64880a9 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-module/a-default-explicit.mts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 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 default 'a'; diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-module/a-default.ts b/packages/cli/src/tests/transforms/__fixtures__/pkg-module/a-default.ts new file mode 100644 index 0000000000..a3d64880a9 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-module/a-default.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 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 default 'a'; diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-module/a-named-explicit.mts b/packages/cli/src/tests/transforms/__fixtures__/pkg-module/a-named-explicit.mts new file mode 100644 index 0000000000..132570f2bb --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-module/a-named-explicit.mts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 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 const value = 'a'; diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-module/a-named.ts b/packages/cli/src/tests/transforms/__fixtures__/pkg-module/a-named.ts new file mode 100644 index 0000000000..132570f2bb --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-module/a-named.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 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 const value = 'a'; diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-module/b-default.mts b/packages/cli/src/tests/transforms/__fixtures__/pkg-module/b-default.mts new file mode 100644 index 0000000000..57d07f2c30 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-module/b-default.mts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 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 default 'b'; diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-module/b-named.mts b/packages/cli/src/tests/transforms/__fixtures__/pkg-module/b-named.mts new file mode 100644 index 0000000000..be9bb39348 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-module/b-named.mts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 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 const value = 'b'; diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-module/c-default.cts b/packages/cli/src/tests/transforms/__fixtures__/pkg-module/c-default.cts new file mode 100644 index 0000000000..48f5b4f136 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-module/c-default.cts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 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 default 'c'; diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-module/c-named.cts b/packages/cli/src/tests/transforms/__fixtures__/pkg-module/c-named.cts new file mode 100644 index 0000000000..c3e5b58572 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-module/c-named.cts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 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 const value = 'c'; diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-module/main-explicit.mts b/packages/cli/src/tests/transforms/__fixtures__/pkg-module/main-explicit.mts new file mode 100644 index 0000000000..7f45ea506c --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-module/main-explicit.mts @@ -0,0 +1,73 @@ +/* + * Copyright 2024 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. + */ + +// @ts-nocheck + +import * as depCommonJs from 'dep-commonjs'; +import * as depModule from 'dep-module'; +import * as depDefault from 'dep-default'; +import { value as namedA } from './a-named-explicit'; +import { value as namedB } from './b-named'; +import cNamed from './c-named'; +import defaultA from './a-default-explicit'; +import defaultB from './b-default'; +import cDefault from './c-default'; + +const { default: defaultC } = cDefault; +const { value: namedC } = cNamed; + +async function resolveAll(obj): Promise { + const val = await obj; + if (typeof val !== 'object' || val === null) { + return val; + } + if (Array.isArray(val)) { + return await Promise.all(val.map(resolveAll)); + } + return Object.fromEntries( + await Promise.all( + Object.entries(obj).map(async ([key, value]) => [ + key, + await resolveAll(await value), + ]), + ), + ); +} + +export const values = resolveAll({ + depCommonJs, + depModule, + depDefault, + dynCommonJs: import('dep-commonjs'), + dynModule: import('dep-module'), + dynDefault: import('dep-default'), + dep: { + namedA, + namedB, + namedC, + defaultA, + defaultB, + defaultC, + }, + dyn: { + namedA: import('./a-named-explicit').then(m => m.value), + namedB: import('./b-named').then(m => m.value), + namedC: import('./c-named').then(m => m.default.value), + defaultA: import('./a-default-explicit').then(m => m.default), + defaultB: import('./b-default').then(m => m.default), + defaultC: import('./c-default').then(m => m.default.default), + }, +}); diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-module/main.ts b/packages/cli/src/tests/transforms/__fixtures__/pkg-module/main.ts new file mode 100644 index 0000000000..a4133495c7 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-module/main.ts @@ -0,0 +1,72 @@ +/* + * Copyright 2024 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 * as depCommonJs from 'dep-commonjs'; +import * as depModule from 'dep-module'; +import * as depDefault from 'dep-default'; +import { value as namedA } from './a-named'; +import { value as namedB } from './b-named.mts'; +import { value as namedC } from './c-named.cts'; +import defaultA from './a-default'; +import defaultB from './b-default.mts'; +import cDefault from './c-default.cts'; + +// @ts-expect-error Default exports from CommonJS are not well supported +const { default: defaultC } = cDefault; + +async function resolveAll(obj: object): Promise { + const val = await obj; + if (typeof val !== 'object' || val === null) { + return val; + } + if (Array.isArray(val)) { + return await Promise.all(val.map(resolveAll)); + } + return Object.fromEntries( + await Promise.all( + Object.entries(obj).map(async ([key, value]) => [ + key, + await resolveAll(await value), + ]), + ), + ); +} + +export const values = resolveAll({ + depCommonJs, + depModule, + depDefault, + dynCommonJs: import('dep-commonjs'), + dynModule: import('dep-module'), + dynDefault: import('dep-default'), + dep: { + namedA, + namedB, + namedC, + defaultA, + defaultB, + defaultC, + }, + dyn: { + namedA: import('./a-named').then(m => m.value), + namedB: import('./b-named.mts').then(m => m.value), + namedC: import('./c-named.cts').then(m => m.value), + defaultA: import('./a-default').then(m => m.default), + defaultB: import('./b-default.mts').then(m => m.default), + // @ts-expect-error Default exports from CommonJS are not well supported + defaultC: import('./c-default.cts').then(m => m.default.default), + }, +}); diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-module/package.json b/packages/cli/src/tests/transforms/__fixtures__/pkg-module/package.json new file mode 100644 index 0000000000..f7b7e1ab30 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-module/package.json @@ -0,0 +1,8 @@ +{ + "name": "pkg-module", + "type": "module", + "exports": { + ".": "./main.ts", + "./print": "./print.ts" + } +} diff --git a/packages/cli/src/tests/transforms/__fixtures__/pkg-module/print.ts b/packages/cli/src/tests/transforms/__fixtures__/pkg-module/print.ts new file mode 100644 index 0000000000..7c24f99f37 --- /dev/null +++ b/packages/cli/src/tests/transforms/__fixtures__/pkg-module/print.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2024 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 { values } from './main'; + +values.then(obj => console.log(JSON.stringify(obj, null, 2))); diff --git a/packages/cli/src/tests/transforms/transforms.test.ts b/packages/cli/src/tests/transforms/transforms.test.ts new file mode 100644 index 0000000000..52af9a47d3 --- /dev/null +++ b/packages/cli/src/tests/transforms/transforms.test.ts @@ -0,0 +1,259 @@ +/* + * Copyright 2024 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 { execFileSync } from 'child_process'; +import { resolve as resolvePath } from 'path'; +import { Output, buildPackage } from '../../lib/builder'; + +const exportValues = { + all: { + namedA: 'a', + namedB: 'b', + namedC: 'c', + defaultA: 'a', + defaultB: 'b', + defaultC: 'c', + }, + commonJs: { + namedA: 'a', + namedC: 'c', + defaultA: 'a', + defaultC: 'c', + }, +}; + +const expectedExports = { + commonJs: { + ...exportValues.commonJs, + dyn: exportValues.all, + default: { + ...exportValues.commonJs, + dyn: exportValues.all, + }, + }, + module: { + ...exportValues.all, + dyn: exportValues.all, + }, +}; + +function loadFixture(fixture: string) { + const output = execFileSync( + 'node', + [ + '--import', + '@backstage/cli/config/nodeTransform.cjs', + resolvePath(__dirname, `__fixtures__/${fixture}`), + ], + { encoding: 'utf8' }, + ); + return JSON.parse(output); +} + +describe('node runtime module transforms', () => { + it('should load from commonjs format', async () => { + expect(loadFixture('pkg-commonjs/print.ts')).toEqual({ + depCommonJs: expectedExports.commonJs, + depDefault: expectedExports.commonJs, + dynCommonJs: expectedExports.commonJs, + dynDefault: expectedExports.commonJs, + dynModule: expectedExports.module, + dep: exportValues.commonJs, + dyn: exportValues.all, + }); + }); + + it('should load from default format', async () => { + expect(loadFixture('pkg-default/print.ts')).toEqual({ + depCommonJs: expectedExports.commonJs, + depDefault: expectedExports.commonJs, + dynCommonJs: expectedExports.commonJs, + dynDefault: expectedExports.commonJs, + dynModule: expectedExports.module, + dep: exportValues.commonJs, + dyn: exportValues.all, + }); + }); + + it('should load from module format', async () => { + expect(loadFixture('pkg-module/print.ts')).toEqual({ + depCommonJs: expectedExports.commonJs, + depDefault: expectedExports.commonJs, + depModule: expectedExports.module, + dynCommonJs: expectedExports.commonJs, + dynDefault: expectedExports.commonJs, + dynModule: expectedExports.module, + dep: exportValues.all, + dyn: exportValues.all, + }); + }); +}); + +describe('Jest runtime module transforms', () => { + it('should load from commonjs format', async () => { + const values = await import('./__fixtures__/pkg-commonjs/main').then( + m => m.values, + ); + expect(values).toEqual({ + depCommonJs: expectedExports.commonJs, + depDefault: expectedExports.commonJs, + dynCommonJs: expectedExports.commonJs, + dynDefault: expectedExports.commonJs, + dynModule: expectedExports.module, + dep: exportValues.commonJs, + dyn: exportValues.all, + }); + }); + + it('should load from default format', async () => { + const values = await import('./__fixtures__/pkg-default/main').then( + m => m.values, + ); + expect(values).toEqual({ + depCommonJs: expectedExports.commonJs, + depDefault: expectedExports.commonJs, + dynCommonJs: expectedExports.commonJs, + dynDefault: expectedExports.commonJs, + dynModule: expectedExports.module, + dep: exportValues.commonJs, + dyn: exportValues.all, + }); + }); + + it('should load from module format', async () => { + // This uses a separate entry point with an explicit .mts extension. This is + // because we can't cleanly switch the Jest behavior based on type=module in + // package.json for .ts files in Jest. If a module type is detected we + // instead need to switch the transforms for the entire Jest project, which + // we can't do for this test. We instead use the explicit .mts extension to + // verify the transform behavior. + + // @ts-expect-error Cannot find module './__fixtures__/pkg-module/main-explicit' or its corresponding type declarations. + const values = await import('./__fixtures__/pkg-module/main-explicit').then( + m => m.values, + ); + expect(values).toEqual({ + depCommonJs: expectedExports.commonJs, + depDefault: expectedExports.commonJs, + depModule: expectedExports.module, + dynCommonJs: expectedExports.commonJs, + dynDefault: expectedExports.commonJs, + dynModule: expectedExports.module, + dep: exportValues.all, + dyn: exportValues.all, + }); + }); +}); + +describe('package build transforms', () => { + it('should build and load from commonjs format', async () => { + const pkgPath = resolvePath(__dirname, '__fixtures__/pkg-commonjs'); + + await buildPackage({ + targetDir: pkgPath, + outputs: new Set([Output.cjs]), + workspacePackages: [], + }); + const values = await import(resolvePath(pkgPath, 'dist/index.cjs.js')).then( + m => m.values, + ); + expect(values).toEqual({ + depCommonJs: expectedExports.commonJs, + depDefault: expectedExports.commonJs, + dynCommonJs: expectedExports.commonJs, + dynDefault: expectedExports.commonJs, + dynModule: expectedExports.module, + dep: exportValues.commonJs, + dyn: exportValues.all, + }); + + expect(loadFixture('pkg-commonjs/dist/print.cjs.js')).toEqual({ + depCommonJs: expectedExports.commonJs, + depDefault: expectedExports.commonJs, + dynCommonJs: expectedExports.commonJs, + dynDefault: expectedExports.commonJs, + dynModule: expectedExports.module, + dep: exportValues.commonJs, + dyn: exportValues.all, + }); + }); + + it('should build and load from default format', async () => { + const pkgPath = resolvePath(__dirname, '__fixtures__/pkg-default'); + + await buildPackage({ + targetDir: pkgPath, + outputs: new Set([Output.cjs]), + workspacePackages: [], + }); + const values = await import(resolvePath(pkgPath, 'dist/index.cjs.js')).then( + m => m.values, + ); + expect(values).toEqual({ + depCommonJs: expectedExports.commonJs, + depDefault: expectedExports.commonJs, + dynCommonJs: expectedExports.commonJs, + dynDefault: expectedExports.commonJs, + dynModule: expectedExports.module, + dep: exportValues.commonJs, + dyn: exportValues.all, + }); + + expect(loadFixture('pkg-default/dist/print.cjs.js')).toEqual({ + depCommonJs: expectedExports.commonJs, + depDefault: expectedExports.commonJs, + dynCommonJs: expectedExports.commonJs, + dynDefault: expectedExports.commonJs, + dynModule: expectedExports.module, + dep: exportValues.commonJs, + dyn: exportValues.all, + }); + }); + + it('should build and load from module format', async () => { + const pkgPath = resolvePath(__dirname, '__fixtures__/pkg-module'); + + await buildPackage({ + targetDir: pkgPath, + outputs: new Set([Output.cjs]), + workspacePackages: [], + }); + const values = await import(resolvePath(pkgPath, 'dist/index.mjs')).then( + m => m.values, + ); + expect(values).toEqual({ + depCommonJs: expectedExports.commonJs, + depDefault: expectedExports.commonJs, + depModule: expectedExports.module, + dynCommonJs: expectedExports.commonJs, + dynDefault: expectedExports.commonJs, + dynModule: expectedExports.module, + dep: exportValues.all, + dyn: exportValues.all, + }); + + expect(loadFixture('pkg-module/dist/print.mjs')).toEqual({ + depCommonJs: expectedExports.commonJs, + depDefault: expectedExports.commonJs, + depModule: expectedExports.module, + dynCommonJs: expectedExports.commonJs, + dynDefault: expectedExports.commonJs, + dynModule: expectedExports.module, + dep: exportValues.all, + dyn: exportValues.all, + }); + }); +}); diff --git a/packages/cli/src/wiring/CliInitializer.ts b/packages/cli/src/wiring/CliInitializer.ts index 9fe8bb8084..5acb88151f 100644 --- a/packages/cli/src/wiring/CliInitializer.ts +++ b/packages/cli/src/wiring/CliInitializer.ts @@ -22,16 +22,23 @@ import { version } from '../lib/version'; import chalk from 'chalk'; import { exitWithError } from '../lib/errors'; import { assertError } from '@backstage/errors'; +import { isPromise } from 'util/types'; -type UninitializedFeature = CliFeature | Promise; +type UninitializedFeature = CliFeature | Promise<{ default: CliFeature }>; export class CliInitializer { private graph = new CommandGraph(); private commandRegistry = new CommandRegistry(this.graph); #uninitiazedFeatures: Promise[] = []; - add(module: UninitializedFeature) { - this.#uninitiazedFeatures.push(Promise.resolve(module)); + add(feature: UninitializedFeature) { + if (isPromise(feature)) { + this.#uninitiazedFeatures.push( + feature.then(f => unwrapFeature(f.default)), + ); + } else { + this.#uninitiazedFeatures.push(Promise.resolve(feature)); + } } async #register(feature: CliFeature) { @@ -136,3 +143,22 @@ function isCliPlugin(feature: CliFeature): feature is InternalCliPlugin { // Backwards compatibility for v1 registrations that use duck typing return 'plugin' in internal; } + +/** @internal */ +export function unwrapFeature( + feature: CliFeature | { default: CliFeature }, +): CliFeature { + 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) { + return feature.default; + } + + return feature; +} diff --git a/packages/config-loader/src/schema/collect.ts b/packages/config-loader/src/schema/collect.ts index c7af64e0f3..16bd654946 100644 --- a/packages/config-loader/src/schema/collect.ts +++ b/packages/config-loader/src/schema/collect.ts @@ -183,9 +183,8 @@ async function compileTsSchemas( // Lazy loaded, because this brings up all of TypeScript and we don't // want that eagerly loaded in tests - const { getProgramFromFiles, buildGenerator } = await import( - 'typescript-json-schema' - ); + const { getProgramFromFiles, buildGenerator } = + require('typescript-json-schema') as typeof import('typescript-json-schema'); const program = getProgramFromFiles( entries.map(({ path }) => path), diff --git a/packages/repo-tools/src/commands/index.ts b/packages/repo-tools/src/commands/index.ts index 4e984c72de..65ab43d376 100644 --- a/packages/repo-tools/src/commands/index.ts +++ b/packages/repo-tools/src/commands/index.ts @@ -39,9 +39,7 @@ function registerPackageCommand(program: Command) { 'Initialize any required files to use the OpenAPI tooling for this package.', ) .action( - lazy(() => - import('./package/schema/openapi/init').then(m => m.singleCommand), - ), + lazy(() => import('./package/schema/openapi/init'), 'singleCommand'), ); openApiCommand @@ -60,11 +58,7 @@ function registerPackageCommand(program: Command) { ) .option('--watch') .description('Watch the OpenAPI spec for changes and regenerate on save.') - .action( - lazy(() => - import('./package/schema/openapi/generate').then(m => m.command), - ), - ); + .action(lazy(() => import('./package/schema/openapi/generate'), 'command')); openApiCommand .command('fuzz') @@ -81,18 +75,14 @@ function registerPackageCommand(program: Command) { '--exclude-checks ', 'Exclude checks from schemathesis run', ) - .action( - lazy(() => import('./package/schema/openapi/fuzz').then(m => m.command)), - ); + .action(lazy(() => import('./package/schema/openapi/fuzz'), 'command')); openApiCommand .command('diff') .option('--ignore', 'Ignore linting failures and only log the results.') .option('--json', 'Output the results as JSON') .option('--since ', 'Diff the API against a specific ref') - .action( - lazy(() => import('./package/schema/openapi/diff').then(m => m.command)), - ); + .action(lazy(() => import('./package/schema/openapi/diff'), 'command')); } function registerRepoCommand(program: Command) { @@ -113,11 +103,7 @@ function registerRepoCommand(program: Command) { .description( 'Verify that all OpenAPI schemas are valid and set up correctly.', ) - .action( - lazy(() => - import('./repo/schema/openapi/verify').then(m => m.bulkCommand), - ), - ); + .action(lazy(() => import('./repo/schema/openapi/verify'), 'bulkCommand')); openApiCommand .command('lint [paths...]') @@ -126,17 +112,13 @@ function registerRepoCommand(program: Command) { '--strict', 'Fail on any linting severity messages, not just errors.', ) - .action( - lazy(() => import('./repo/schema/openapi/lint').then(m => m.bulkCommand)), - ); + .action(lazy(() => import('./repo/schema/openapi/lint'), 'bulkCommand')); openApiCommand .command('test [paths...]') .description('Test OpenAPI schemas against written tests') .option('--update', 'Update the spec on failure.') - .action( - lazy(() => import('./repo/schema/openapi/test').then(m => m.bulkCommand)), - ); + .action(lazy(() => import('./repo/schema/openapi/test'), 'bulkCommand')); openApiCommand .command('fuzz') @@ -145,9 +127,7 @@ function registerRepoCommand(program: Command) { '--since ', 'Only fuzz packages that have changed since the given ref', ) - .action( - lazy(() => import('./repo/schema/openapi/fuzz').then(m => m.command)), - ); + .action(lazy(() => import('./repo/schema/openapi/fuzz'), 'command')); openApiCommand .command('diff') @@ -159,9 +139,7 @@ function registerRepoCommand(program: Command) { 'Diff the API against a specific ref', 'origin/master', ) - .action( - lazy(() => import('./repo/schema/openapi/diff').then(m => m.command)), - ); + .action(lazy(() => import('./repo/schema/openapi/diff'), 'command')); } function registerLintCommand(program: Command) { @@ -174,10 +152,10 @@ function registerLintCommand(program: Command) { 'Lint backend plugin packages for legacy exports and make sure it conforms to the new export pattern', ) .action( - lazy(() => - import( - './lint-legacy-backend-exports/lint-legacy-backend-exports' - ).then(m => m.lint), + lazy( + () => + import('./lint-legacy-backend-exports/lint-legacy-backend-exports'), + 'lint', ), ); } @@ -216,16 +194,12 @@ export function registerCommands(program: Command) { 'Turn on release tag validation for the public, beta, and alpha APIs', ) .description('Generate an API report for selected packages') - .action( - lazy(() => - import('./api-reports/api-reports').then(m => m.buildApiReports), - ), - ); + .action(lazy(() => import('./api-reports/api-reports'), 'buildApiReports')); program .command('type-deps') .description('Find inconsistencies in types of all packages and plugins') - .action(lazy(() => import('./type-deps/type-deps').then(m => m.default))); + .action(lazy(() => import('./type-deps/type-deps'), 'default')); program .command('peer-deps') @@ -233,7 +207,7 @@ export function registerCommands(program: Command) { 'Ensure your packages are using the correct peer dependency format.', ) .option('--fix', 'Fix the issues found') - .action(lazy(() => import('./peer-deps/peer-deps').then(m => m.default))); + .action(lazy(() => import('./peer-deps/peer-deps'), 'default')); program .command('generate-catalog-info') @@ -247,10 +221,9 @@ export function registerCommands(program: Command) { ) .description('Create or fix info yaml files for all backstage packages') .action( - lazy(() => - import('./generate-catalog-info/generate-catalog-info').then( - m => m.default, - ), + lazy( + () => import('./generate-catalog-info/generate-catalog-info'), + 'default', ), ); @@ -279,20 +252,14 @@ export function registerCommands(program: Command) { .description( 'Generate a patch for the selected package in the target repository', ) - .action( - lazy(() => - import('./generate-patch/generate-patch').then(m => m.default), - ), - ); + .action(lazy(() => import('./generate-patch/generate-patch'), 'default')); program .command('knip-reports [paths...]') .option('--ci', 'CI run checks that there is no changes on knip reports') .description('Generate a knip report for selected packages') .action( - lazy(() => - import('./knip-reports/knip-reports').then(m => m.buildKnipReports), - ), + lazy(() => import('./knip-reports/knip-reports'), 'buildKnipReports'), ); registerPackageCommand(program); @@ -300,13 +267,25 @@ export function registerCommands(program: Command) { registerLintCommand(program); } +type ActionFunc = (...args: any[]) => Promise; +type ActionExports = { + [KName in keyof TModule as TModule[KName] extends ActionFunc + ? KName + : never]: TModule[KName]; +}; + // Wraps an action function so that it always exits and handles errors -function lazy( - getActionFunc: () => Promise<(...args: any[]) => Promise>, +export function lazy( + moduleLoader: () => Promise, + exportName: keyof ActionExports, ): (...args: any[]) => Promise { return async (...args: any[]) => { try { - const actionFunc = await getActionFunc(); + const mod = await moduleLoader(); + const actualModule = ( + mod as unknown as { default: ActionExports } + ).default; + const actionFunc = actualModule[exportName] as ActionFunc; await actionFunc(...args); process.exit(0); diff --git a/packages/repo-tools/src/commands/repo/schema/openapi/verify.ts b/packages/repo-tools/src/commands/repo/schema/openapi/verify.ts index 61aadc5d0c..9eab1c77f5 100644 --- a/packages/repo-tools/src/commands/repo/schema/openapi/verify.ts +++ b/packages/repo-tools/src/commands/repo/schema/openapi/verify.ts @@ -54,7 +54,7 @@ async function verify(directoryPath: string) { schemaPath = join(directoryPath, OLD_SCHEMA_PATH); } - const schema = await import(resolvePath(schemaPath)); + const { default: schema } = await import(resolvePath(schemaPath)); if (!schema.spec) { throw new Error(`\`${TS_SCHEMA_PATH}\` needs to have a 'spec' export.`); diff --git a/plugins/catalog-backend/src/database/DefaultProcessingDatabase.ts b/plugins/catalog-backend/src/database/DefaultProcessingDatabase.ts index ee836ff038..bb5c38de18 100644 --- a/plugins/catalog-backend/src/database/DefaultProcessingDatabase.ts +++ b/plugins/catalog-backend/src/database/DefaultProcessingDatabase.ts @@ -383,9 +383,12 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { } } - // Replace all references for the originating entity or source and then create new ones + // Lastly, replace refresh state references for the originating entity and any successfully added entities await tx('refresh_state_references') - .andWhere({ source_entity_ref: options.sourceEntityRef }) + // Remove all existing references from the originating entity + .where({ source_entity_ref: options.sourceEntityRef }) + // And remove any existing references to entities that we're inserting new references for + .orWhereIn('target_entity_ref', stateReferences) .delete(); await tx.batchInsert( 'refresh_state_references', diff --git a/plugins/catalog-backend/src/database/DefaultProviderDatabase.ts b/plugins/catalog-backend/src/database/DefaultProviderDatabase.ts index 03f72e06b7..60dd43bb59 100644 --- a/plugins/catalog-backend/src/database/DefaultProviderDatabase.ts +++ b/plugins/catalog-backend/src/database/DefaultProviderDatabase.ts @@ -162,13 +162,11 @@ export class DefaultProviderDatabase implements ProviderDatabase { logger: this.options.logger, }); } - - await tx('refresh_state_references') - .where('target_entity_ref', entityRef) - .andWhere({ source_key: options.sourceKey }) - .delete(); - if (ok) { + await tx('refresh_state_references') + .where('target_entity_ref', entityRef) + .delete(); + await tx( 'refresh_state_references', ).insert({ @@ -176,6 +174,11 @@ export class DefaultProviderDatabase implements ProviderDatabase { target_entity_ref: entityRef, }); } else { + await tx('refresh_state_references') + .where('target_entity_ref', entityRef) + .andWhere({ source_key: options.sourceKey }) + .delete(); + const conflictingKey = await checkLocationKeyConflict({ tx, entityRef, diff --git a/plugins/catalog-backend/src/tests/integration.test.ts b/plugins/catalog-backend/src/tests/integration.test.ts index e85ebe48ce..0c8e475b39 100644 --- a/plugins/catalog-backend/src/tests/integration.test.ts +++ b/plugins/catalog-backend/src/tests/integration.test.ts @@ -32,6 +32,7 @@ import { import { PermissionEvaluator } from '@backstage/plugin-permission-common'; import { createHash } from 'crypto'; import { Knex } from 'knex'; +import merge from 'lodash/merge'; import { EntitiesCatalog } from '../catalog/types'; import { DefaultCatalogDatabase } from '../database/DefaultCatalogDatabase'; import { DefaultProcessingDatabase } from '../database/DefaultProcessingDatabase'; @@ -55,6 +56,7 @@ import { mockServices } from '@backstage/backend-test-utils'; import { LoggerService } from '@backstage/backend-plugin-api'; import { DatabaseManager } from '@backstage/backend-common'; import { entitiesResponseToObjects } from '../service/response'; +import { deleteOrphanedEntities } from '../database/operations/util/deleteOrphanedEntities'; const voidLogger = mockServices.logger.mock(); @@ -196,12 +198,14 @@ class TestHarness { readonly #refresh: RefreshService; readonly #provider: TestProvider; readonly #proxyProgressTracker: ProxyProgressTracker; + readonly #db: Knex; static async create(options?: { disableRelationsCompatibility?: boolean; logger?: LoggerService; db?: Knex; permissions?: PermissionEvaluator; + additionalProviders?: EntityProvider[]; processEntity?( entity: Entity, location: LocationSpec, @@ -301,8 +305,13 @@ class TestHarness { const refresh = new DefaultRefreshService({ database: catalogDatabase }); const provider = new TestProvider(); + const providers: EntityProvider[] = [provider]; - await connectEntityProviders(providerDatabase, [provider]); + if (options?.additionalProviders) { + providers.push(...options.additionalProviders); + } + + await connectEntityProviders(providerDatabase, providers); return new TestHarness( catalog, @@ -319,6 +328,7 @@ class TestHarness { refresh, provider, proxyProgressTracker, + db, ); } @@ -328,12 +338,14 @@ class TestHarness { refresh: RefreshService, provider: TestProvider, proxyProgressTracker: ProxyProgressTracker, + db: Knex, ) { this.#catalog = catalog; this.#engine = engine; this.#refresh = refresh; this.#provider = provider; this.#proxyProgressTracker = proxyProgressTracker; + this.#db = db; } async process(entityRefs?: Set) { @@ -353,7 +365,7 @@ class TestHarness { } async setInputEntities(entities: (Entity & { locationKey?: string })[]) { - return this.#provider.getConnection().applyMutation({ + await this.#provider.getConnection().applyMutation({ type: 'full', entities: entities.map(({ locationKey, ...entity }) => ({ entity, @@ -375,6 +387,55 @@ class TestHarness { async refresh(options: RefreshOptions) { return this.#refresh.refresh(options); } + + async removeOrphanedEntities() { + await deleteOrphanedEntities({ + knex: this.#db, + strategy: { mode: 'immediate' }, + }); + } + + async getRefreshState(): Promise< + Record< + string, + { + id: string; + unprocessedEntity: Entity; + processedEntity: Entity; + locationKey: string | null; + } + > + > { + const result = await this.#db('refresh_state').select('*'); + return Object.fromEntries( + result.map(r => [ + r.entity_ref, + { + id: r.entity_id, + unprocessedEntity: JSON.parse(r.unprocessed_entity), + processedEntity: r.processed_entity + ? JSON.parse(r.processed_entity) + : undefined, + locationKey: r.location_key, + }, + ]), + ); + } + + async getRefreshStateReferences(): Promise< + Array<{ + sourceKey: string | null; + sourceEntityRef: string | null; + targetEntityRef: string; + }> + > { + const result = await this.#db('refresh_state_references').select('*'); + return result.map(r => ({ + sourceKey: r.source_key ?? undefined, + sourceEntityRef: r.source_entity_ref ?? undefined, + targetEntityRef: r.target_entity_ref, + })); + } } describe('Catalog Backend Integration', () => { @@ -838,4 +899,269 @@ describe('Catalog Backend Integration', () => { }, }); }); + + it('should replace any refresh_state_references that are dangling after claiming an entityRef with locationKey', async () => { + const firstProvider = new TestProvider(); + firstProvider.getProviderName = () => 'first'; + + const secondProvider = new TestProvider(); + secondProvider.getProviderName = () => 'second'; + + const harness = await TestHarness.create({ + additionalProviders: [firstProvider, secondProvider], + }); + + await firstProvider.getConnection().applyMutation({ + type: 'full', + entities: [ + { + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'component-1', + annotations: { + 'backstage.io/managed-by-location': 'url:.', + 'backstage.io/managed-by-origin-location': 'url:.', + }, + }, + spec: { + type: 'service', + owner: 'no-location-key', + }, + }, + }, + ], + }); + + await expect(harness.process()).resolves.toEqual({}); + + await expect(harness.getOutputEntities()).resolves.toEqual({ + 'component:default/component-1': expect.objectContaining({ + spec: { + type: 'service', + owner: 'no-location-key', + }, + }), + }); + + await secondProvider.getConnection().applyMutation({ + type: 'full', + entities: [ + { + locationKey: 'takeover', + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'component-1', + annotations: { + 'backstage.io/managed-by-location': 'url:.', + 'backstage.io/managed-by-origin-location': 'url:.', + }, + }, + spec: { + type: 'service', + owner: 'location-key', + }, + }, + }, + ], + }); + + await expect(harness.process()).resolves.toEqual({}); + + await expect(harness.getOutputEntities()).resolves.toEqual({ + 'component:default/component-1': expect.objectContaining({ + spec: { + type: 'service', + owner: 'location-key', + }, + }), + }); + + await expect(harness.getRefreshStateReferences()).resolves.toEqual([ + { + sourceKey: 'second', + targetEntityRef: 'component:default/component-1', + }, + ]); + + await secondProvider.getConnection().applyMutation({ + type: 'full', + entities: [ + { + locationKey: 'takeover', + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'component-2', + annotations: { + 'backstage.io/managed-by-location': 'url:.', + 'backstage.io/managed-by-origin-location': 'url:.', + }, + }, + spec: { + type: 'service', + owner: 'location-key', + }, + }, + }, + ], + }); + + await expect(harness.process()).resolves.toEqual({}); + + await expect(harness.getRefreshStateReferences()).resolves.toEqual([ + { + sourceKey: 'second', + targetEntityRef: 'component:default/component-2', + }, + ]); + + await expect(harness.getOutputEntities()).resolves.toEqual({ + 'component:default/component-2': expect.objectContaining({ + spec: { + type: 'service', + owner: 'location-key', + }, + }), + }); + }); + + function withOutputFields(entity: Entity) { + return { + ...entity, + metadata: { + ...entity.metadata, + etag: expect.any(String), + uid: expect.any(String), + }, + relations: [], + }; + } + + it('should fully replace existing entities when emitting override entities during processing', async () => { + const baseEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + annotations: { + 'backstage.io/managed-by-location': 'url:.', + 'backstage.io/managed-by-origin-location': 'url:.', + }, + }, + }; + const entityA = merge({ metadata: { name: 'a' } }, baseEntity); + const entityB = merge({ metadata: { name: 'b' } }, baseEntity); + const entityBOverride = merge({ metadata: { override: true } }, entityB); + + const processEntity = jest.fn( + async ( + entity: Entity, + _location: LocationSpec, + _emit: CatalogProcessorEmit, + ) => entity, + ); + const harness = await TestHarness.create({ processEntity }); + + processEntity.mockImplementation(async (entity, location, emit) => { + if (entity.metadata.name === entityA.metadata.name) { + emit(processingResult.entity(location, entityBOverride)); + } + return entity; + }); + + // A and B are added to the catalog, but the processor emits B' from A that overrides B + await harness.setInputEntities([entityA, entityB]); + await expect(harness.process()).resolves.toEqual({}); + + // Expect to find A and B' in the catalog + await expect(harness.getRefreshStateReferences()).resolves.toEqual([ + { + sourceKey: 'test', + targetEntityRef: 'component:default/a', + }, + { + sourceEntityRef: 'component:default/a', + targetEntityRef: 'component:default/b', + }, + ]); + await expect(harness.getRefreshState()).resolves.toEqual({ + 'component:default/a': expect.objectContaining({ + locationKey: null, + unprocessedEntity: entityA, + }), + 'component:default/b': expect.objectContaining({ + locationKey: 'url:.', + unprocessedEntity: entityBOverride, + }), + }); + await expect(harness.getOutputEntities()).resolves.toEqual({ + 'component:default/a': withOutputFields(entityA), + 'component:default/b': withOutputFields(entityBOverride), + }); + + // Stop emitting B' from A, then do a full sync with A and B + processEntity.mockImplementation(async entity => entity); + await harness.setInputEntities([entityA, entityB]); + + // At this point we should still have A and B' in the catalog + await expect(harness.getRefreshStateReferences()).resolves.toEqual([ + { + sourceKey: 'test', + targetEntityRef: 'component:default/a', + }, + { + sourceEntityRef: 'component:default/a', + targetEntityRef: 'component:default/b', + }, + ]); + await expect(harness.getRefreshState()).resolves.toEqual({ + 'component:default/a': expect.objectContaining({ + locationKey: null, + unprocessedEntity: entityA, + }), + 'component:default/b': expect.objectContaining({ + locationKey: 'url:.', + unprocessedEntity: entityBOverride, + }), + }); + await expect(harness.getOutputEntities()).resolves.toEqual({ + 'component:default/a': withOutputFields(entityA), + 'component:default/b': withOutputFields(entityBOverride), + }); + + // Once we process, B' should be orphaned + await expect(harness.process()).resolves.toEqual({}); + // This is expected to remove B' + await harness.removeOrphanedEntities(); + + // At this point only A is left in the catalog + await expect(harness.getRefreshStateReferences()).resolves.toEqual([ + { + sourceKey: 'test', + targetEntityRef: 'component:default/a', + }, + ]); + await expect(harness.getRefreshState()).resolves.toEqual({ + 'component:default/a': expect.objectContaining({ + locationKey: null, + unprocessedEntity: entityA, + }), + }); + await expect(harness.getOutputEntities()).resolves.toEqual({ + 'component:default/a': withOutputFields(entityA), + }); + + // Next time the provider runs and does a full sync we should now be able to add back B + await harness.setInputEntities([entityA, entityB]); + await expect(harness.process()).resolves.toEqual({}); + + await expect(harness.getOutputEntities()).resolves.toEqual({ + 'component:default/a': withOutputFields(entityA), + 'component:default/b': withOutputFields(entityB), + }); + }); }); diff --git a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts index e0536c50ac..d4edcd1e89 100644 --- a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts +++ b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts @@ -742,5 +742,53 @@ describe.each(databases.eachSupportedId())( expect(settings).toEqual(notificationSettings); }); }); + + describe('topics', () => { + it('should return all topics for user', async () => { + await storage.saveNotification(testNotification1); + await storage.saveNotification(testNotification2); + await storage.saveBroadcast(testNotification3); + await storage.saveNotification(otherUserNotification); + + const topics = await storage.getTopics({ user }); + expect(topics.topics.sort()).toEqual([ + testNotification1.payload.topic, + testNotification3.payload.topic, + testNotification2.payload.topic, + ]); + }); + + it('should return filtered topics for user by title', async () => { + await storage.saveNotification(testNotification1); + await storage.saveNotification(testNotification2); + await storage.saveBroadcast(testNotification3); + await storage.saveBroadcast(testNotification4); + await storage.saveNotification(otherUserNotification); + + const topics = await storage.getTopics({ + user, + search: 'Notification 3', + }); + expect(topics).toEqual({ + topics: [testNotification3.payload.topic], + }); + }); + + it('should return filtered topics for user by severity', async () => { + await storage.saveNotification(testNotification1); + await storage.saveNotification(testNotification2); + await storage.saveBroadcast(testNotification3); + await storage.saveBroadcast(testNotification4); + await storage.saveNotification(otherUserNotification); + + const topics = await storage.getTopics({ + user, + minimumSeverity: 'critical', + }); + expect(topics).toEqual({ + topics: [testNotification1.payload.topic], + }); + }); + }); }, ); diff --git a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts index 3a00d1c9b5..cf19874ff2 100644 --- a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts +++ b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts @@ -21,6 +21,7 @@ import { NotificationGetOptions, NotificationModifyOptions, NotificationsStore, + TopicGetOptions, } from './NotificationsStore'; import { Notification, @@ -563,4 +564,13 @@ export class DatabaseNotificationsStore implements NotificationsStore { .delete(); await this.db('user_settings').insert(rows); } + + async getTopics(options: TopicGetOptions): Promise<{ topics: string[] }> { + const notificationQuery = this.getNotificationsBaseQuery({ + ...options, + orderField: [{ field: 'topic', order: 'asc' }], + }); + const topics = await notificationQuery.distinct(['topic']); + return { topics: topics.map(row => row.topic) }; + } } diff --git a/plugins/notifications-backend/src/database/NotificationsStore.ts b/plugins/notifications-backend/src/database/NotificationsStore.ts index 2560406e20..a1f4eb6999 100644 --- a/plugins/notifications-backend/src/database/NotificationsStore.ts +++ b/plugins/notifications-backend/src/database/NotificationsStore.ts @@ -48,6 +48,16 @@ export type NotificationModifyOptions = { ids: string[]; } & NotificationGetOptions; +/** @internal */ +export type TopicGetOptions = { + user: string; + search?: string; + read?: boolean; + saved?: boolean; + createdAfter?: Date; + minimumSeverity?: NotificationSeverity; +}; + /** @internal */ export interface NotificationsStore { getNotifications(options: NotificationGetOptions): Promise; @@ -97,4 +107,6 @@ export interface NotificationsStore { user: string; settings: NotificationSettings; }): Promise; + + getTopics(options: TopicGetOptions): Promise<{ topics: string[] }>; } diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index 8a12a53be5..06a0a5ebb0 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -20,6 +20,7 @@ import { DatabaseNotificationsStore, normalizeSeverity, NotificationGetOptions, + TopicGetOptions, } from '../database'; import { v4 as uuid } from 'uuid'; import { CatalogApi } from '@backstage/catalog-client'; @@ -246,24 +247,10 @@ export async function createRouter( } }; - // TODO: Move to use OpenAPI router instead - const router = Router(); - router.use(express.json()); - - const listNotificationsHandler = async (req: Request, res: Response) => { - const user = await getUser(req); - const opts: NotificationGetOptions = { - user: user, - }; - if (req.query.offset) { - opts.offset = Number.parseInt(req.query.offset.toString(), 10); - } - if (req.query.limit) { - opts.limit = Number.parseInt(req.query.limit.toString(), 10); - } - if (req.query.orderField) { - opts.orderField = parseEntityOrderFieldParams(req.query); - } + const appendCommonOptions = ( + req: Request, + opts: NotificationGetOptions | TopicGetOptions, + ) => { if (req.query.search) { opts.search = req.query.search.toString(); } @@ -274,10 +261,6 @@ export async function createRouter( // or keep undefined } - if (req.query.topic) { - opts.topic = req.query.topic.toString(); - } - if (req.query.saved === 'true') { opts.saved = true; } else if (req.query.saved === 'false') { @@ -296,6 +279,32 @@ export async function createRouter( req.query.minimumSeverity.toString(), ); } + }; + + // TODO: Move to use OpenAPI router instead + const router = Router(); + router.use(express.json()); + + const listNotificationsHandler = async (req: Request, res: Response) => { + const user = await getUser(req); + const opts: NotificationGetOptions = { + user: user, + }; + if (req.query.offset) { + opts.offset = Number.parseInt(req.query.offset.toString(), 10); + } + if (req.query.limit) { + opts.limit = Number.parseInt(req.query.limit.toString(), 10); + } + if (req.query.orderField) { + opts.orderField = parseEntityOrderFieldParams(req.query); + } + + if (req.query.topic) { + opts.topic = req.query.topic.toString(); + } + + appendCommonOptions(req, opts); const [notifications, totalCount] = await Promise.all([ store.getNotifications(opts), @@ -357,6 +366,21 @@ export async function createRouter( res.json(notifications[0]); }; + // Get topics + const listTopicsHandler = async (req: Request, res: Response) => { + const user = await getUser(req); + const opts: TopicGetOptions = { + user: user, + }; + + appendCommonOptions(req, opts); + + const topics = await store.getTopics(opts); + res.json(topics); + }; + + router.get('/topics', listTopicsHandler); + // Make sure this is the last "GET" handler router.get('/:id', getNotificationHandler); // Deprecated endpoint router.get('/notifications/:id', getNotificationHandler); diff --git a/plugins/notifications/report.api.md b/plugins/notifications/report.api.md index 17c1e7e338..8b4553cf42 100644 --- a/plugins/notifications/report.api.md +++ b/plugins/notifications/report.api.md @@ -20,16 +20,21 @@ import { RouteRef } from '@backstage/core-plugin-api'; import { TableProps } from '@backstage/core-components'; // @public (undocumented) -export type GetNotificationsOptions = { - offset?: number; - limit?: number; +export type GetNotificationsCommonOptions = { search?: string; read?: boolean; saved?: boolean; createdAfter?: Date; + minimumSeverity?: NotificationSeverity; +}; + +// @public (undocumented) +export type GetNotificationsOptions = GetNotificationsCommonOptions & { + offset?: number; + limit?: number; sort?: 'created' | 'topic' | 'origin'; sortOrder?: 'asc' | 'desc'; - minimumSeverity?: NotificationSeverity; + topic?: string; }; // @public (undocumented) @@ -38,6 +43,14 @@ export type GetNotificationsResponse = { totalCount: number; }; +// @public (undocumented) +export type GetTopicsOptions = GetNotificationsCommonOptions; + +// @public (undocumented) +export type GetTopicsResponse = { + topics: string[]; +}; + // @public (undocumented) export interface NotificationsApi { // (undocumented) @@ -51,6 +64,8 @@ export interface NotificationsApi { // (undocumented) getStatus(): Promise; // (undocumented) + getTopics(options?: GetTopicsOptions): Promise; + // (undocumented) updateNotifications( options: UpdateNotificationsOptions, ): Promise; @@ -77,6 +92,8 @@ export class NotificationsClient implements NotificationsApi { // (undocumented) getStatus(): Promise; // (undocumented) + getTopics(options?: GetTopicsOptions): Promise; + // (undocumented) updateNotifications( options: UpdateNotificationsOptions, ): Promise; diff --git a/plugins/notifications/src/api/NotificationsApi.ts b/plugins/notifications/src/api/NotificationsApi.ts index e24e5cad64..6bf53c2864 100644 --- a/plugins/notifications/src/api/NotificationsApi.ts +++ b/plugins/notifications/src/api/NotificationsApi.ts @@ -27,18 +27,26 @@ export const notificationsApiRef = createApiRef({ }); /** @public */ -export type GetNotificationsOptions = { - offset?: number; - limit?: number; +export type GetNotificationsCommonOptions = { search?: string; read?: boolean; saved?: boolean; createdAfter?: Date; - sort?: 'created' | 'topic' | 'origin'; - sortOrder?: 'asc' | 'desc'; minimumSeverity?: NotificationSeverity; }; +/** @public */ +export type GetNotificationsOptions = GetNotificationsCommonOptions & { + offset?: number; + limit?: number; + sort?: 'created' | 'topic' | 'origin'; + sortOrder?: 'asc' | 'desc'; + topic?: string; +}; + +/** @public */ +export type GetTopicsOptions = GetNotificationsCommonOptions; + /** @public */ export type UpdateNotificationsOptions = { ids: string[]; @@ -52,6 +60,11 @@ export type GetNotificationsResponse = { totalCount: number; }; +/** @public */ +export type GetTopicsResponse = { + topics: string[]; +}; + /** @public */ export interface NotificationsApi { getNotifications( @@ -71,4 +84,6 @@ export interface NotificationsApi { updateNotificationSettings( settings: NotificationSettings, ): Promise; + + getTopics(options?: GetTopicsOptions): Promise; } diff --git a/plugins/notifications/src/api/NotificationsClient.test.ts b/plugins/notifications/src/api/NotificationsClient.test.ts index 8248ea9331..8856e529e6 100644 --- a/plugins/notifications/src/api/NotificationsClient.test.ts +++ b/plugins/notifications/src/api/NotificationsClient.test.ts @@ -22,6 +22,8 @@ import { Notification } from '@backstage/plugin-notifications-common'; const server = setupServer(); +const testTopic = 'test-topic'; + const testNotification: Partial = { user: 'user:default/john.doe', origin: 'plugin-test', @@ -29,6 +31,7 @@ const testNotification: Partial = { title: 'Notification 1', link: '/catalog', severity: 'normal', + topic: testTopic, }, }; @@ -75,6 +78,22 @@ describe('NotificationsClient', () => { expect(response).toEqual(expectedResp); }); + it('should fetch notifications of the topic', async () => { + server.use( + rest.get(`${mockBaseUrl}/notifications`, (req, res, ctx) => { + expect(req.url.search).toBe(`?limit=10&offset=0&topic=${testTopic}`); + return res(ctx.json(expectedResp)); + }), + ); + + const response = await client.getNotifications({ + limit: 10, + offset: 0, + topic: testTopic, + }); + expect(response).toEqual(expectedResp); + }); + it('should omit unselected fetch options', async () => { server.use( rest.get(`${mockBaseUrl}/notifications`, (req, res, ctx) => { @@ -131,4 +150,36 @@ describe('NotificationsClient', () => { expect(response).toEqual(expectedResp); }); }); + + describe('getTopics', () => { + const expectedResp = [testTopic]; + + it('should fetch topics from correct endpoint', async () => { + server.use( + rest.get(`${mockBaseUrl}/topics`, (_, res, ctx) => + res(ctx.json(expectedResp)), + ), + ); + const response = await client.getTopics(); + expect(response).toEqual(expectedResp); + }); + + it('should fetch topics with options', async () => { + server.use( + rest.get(`${mockBaseUrl}/topics`, (req, res, ctx) => { + expect(req.url.search).toBe( + '?search=find+me&read=true&createdAfter=1970-01-01T00%3A00%3A00.005Z', + ); + return res(ctx.json(expectedResp)); + }), + ); + + const response = await client.getTopics({ + search: 'find me', + read: true, + createdAfter: new Date(5), + }); + expect(response).toEqual(expectedResp); + }); + }); }); diff --git a/plugins/notifications/src/api/NotificationsClient.ts b/plugins/notifications/src/api/NotificationsClient.ts index 5e763ecb8c..002553ad22 100644 --- a/plugins/notifications/src/api/NotificationsClient.ts +++ b/plugins/notifications/src/api/NotificationsClient.ts @@ -14,8 +14,11 @@ * limitations under the License. */ import { + GetNotificationsCommonOptions, GetNotificationsOptions, GetNotificationsResponse, + GetTopicsOptions, + GetTopicsResponse, NotificationsApi, UpdateNotificationsOptions, } from './NotificationsApi'; @@ -56,20 +59,11 @@ export class NotificationsClient implements NotificationsApi { `${options.sort},${options?.sortOrder ?? 'desc'}`, ); } - if (options?.search) { - queryString.append('search', options.search); - } - if (options?.read !== undefined) { - queryString.append('read', options.read ? 'true' : 'false'); - } - if (options?.saved !== undefined) { - queryString.append('saved', options.saved ? 'true' : 'false'); - } - if (options?.createdAfter !== undefined) { - queryString.append('createdAfter', options.createdAfter.toISOString()); - } - if (options?.minimumSeverity !== undefined) { - queryString.append('minimumSeverity', options.minimumSeverity); + + this.appendCommonQueryStrings(queryString, options); + + if (options?.topic !== undefined) { + queryString.append('topic', options.topic); } return await this.request( @@ -111,6 +105,34 @@ export class NotificationsClient implements NotificationsApi { }); } + async getTopics(options?: GetTopicsOptions): Promise { + const queryString = new URLSearchParams(); + this.appendCommonQueryStrings(queryString, options); + + return await this.request(`/topics?${queryString}`); + } + + private appendCommonQueryStrings( + queryString: URLSearchParams, + options?: GetNotificationsCommonOptions, + ) { + if (options?.search) { + queryString.append('search', options.search); + } + if (options?.read !== undefined) { + queryString.append('read', options.read ? 'true' : 'false'); + } + if (options?.saved !== undefined) { + queryString.append('saved', options.saved ? 'true' : 'false'); + } + if (options?.createdAfter !== undefined) { + queryString.append('createdAfter', options.createdAfter.toISOString()); + } + if (options?.minimumSeverity !== undefined) { + queryString.append('minimumSeverity', options.minimumSeverity); + } + } + private async request(path: string, init?: RequestInit): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('notifications'); const res = await this.fetchApi.fetch(`${baseUrl}${path}`, init); diff --git a/plugins/notifications/src/components/NotificationsFilters/NotificationsFilters.tsx b/plugins/notifications/src/components/NotificationsFilters/NotificationsFilters.tsx index 0cb88bb3f8..300c56bdee 100644 --- a/plugins/notifications/src/components/NotificationsFilters/NotificationsFilters.tsx +++ b/plugins/notifications/src/components/NotificationsFilters/NotificationsFilters.tsx @@ -40,8 +40,13 @@ export type NotificationsFiltersProps = { onSavedChanged: (checked: boolean | undefined) => void; severity: NotificationSeverity; onSeverityChanged: (severity: NotificationSeverity) => void; + topic?: string; + onTopicChanged: (value: string | undefined) => void; + allTopics?: string[]; }; +const ALL = '___all___'; + export const CreatedAfterOptions: { [key: string]: { label: string; getDate: () => Date }; } = { @@ -127,6 +132,9 @@ export const NotificationsFilters = ({ onSavedChanged, severity, onSeverityChanged, + topic, + onTopicChanged, + allTopics, }: NotificationsFiltersProps) => { const sortByText = getSortByText(sorting); @@ -180,6 +188,15 @@ export const NotificationsFilters = ({ onSeverityChanged(value); }; + const handleOnTopicChanged = ( + event: React.ChangeEvent<{ name?: string; value: unknown }>, + ) => { + const value = event.target.value as string; + onTopicChanged(value === ALL ? undefined : value); + }; + + const sortedAllTopics = (allTopics || []).sort((a, b) => a.localeCompare(b)); + return ( <> @@ -265,6 +282,29 @@ export const NotificationsFilters = ({ + + + + Topic + + + + ); diff --git a/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx b/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx index 0d48a064e5..194033a697 100644 --- a/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx +++ b/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx @@ -33,7 +33,11 @@ import { SortBy, SortByOptions, } from '../NotificationsFilters'; -import { GetNotificationsOptions, GetNotificationsResponse } from '../../api'; +import { + GetNotificationsOptions, + GetNotificationsResponse, + GetTopicsResponse, +} from '../../api'; import { NotificationSeverity, NotificationStatus, @@ -76,9 +80,10 @@ export const NotificationsPage = (props?: NotificationsPageProps) => { SortByOptions.newest.sortBy, ); const [severity, setSeverity] = React.useState('low'); + const [topic, setTopic] = React.useState(); const { error, value, retry, loading } = useNotificationsApi< - [GetNotificationsResponse, NotificationStatus] + [GetNotificationsResponse, NotificationStatus, GetTopicsResponse] >( api => { const options: GetNotificationsOptions = { @@ -94,13 +99,20 @@ export const NotificationsPage = (props?: NotificationsPageProps) => { if (saved !== undefined) { options.saved = saved; } + if (topic !== undefined) { + options.topic = topic; + } const createdAfterDate = CreatedAfterOptions[createdAfter].getDate(); if (createdAfterDate.valueOf() > 0) { options.createdAfter = createdAfterDate; } - return Promise.all([api.getNotifications(options), api.getStatus()]); + return Promise.all([ + api.getNotifications(options), + api.getStatus(), + api.getTopics(options), + ]); }, [ containsText, @@ -111,6 +123,7 @@ export const NotificationsPage = (props?: NotificationsPageProps) => { sorting, saved, severity, + topic, ], ); @@ -143,6 +156,7 @@ export const NotificationsPage = (props?: NotificationsPageProps) => { const notifications = value?.[0]?.notifications; const totalCount = value?.[0]?.totalCount; const isUnread = !!value?.[1]?.unread; + const allTopics = value?.[2]?.topics; let tableTitle = `All notifications (${totalCount})`; if (saved) { @@ -177,6 +191,9 @@ export const NotificationsPage = (props?: NotificationsPageProps) => { onSavedChanged={setSaved} severity={severity} onSeverityChanged={setSeverity} + topic={topic} + onTopicChanged={setTopic} + allTopics={allTopics} /> diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts index 7e204d35df..e368bfb07d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts @@ -38,7 +38,6 @@ import { mockCredentials, mockServices, } from '@backstage/backend-test-utils'; -import stripAnsi from 'strip-ansi'; import { loggerToWinstonLogger } from '@backstage/backend-common'; import { LoggerService } from '@backstage/backend-plugin-api'; @@ -48,6 +47,7 @@ describe('NunjucksWorkflowRunner', () => { let runner: NunjucksWorkflowRunner; let fakeActionHandler: jest.Mock; let fakeTaskLog: jest.Mock; + let stripAnsi: typeof import('strip-ansi').default; const mockDir = createMockDirectory(); @@ -92,9 +92,12 @@ describe('NunjucksWorkflowRunner', () => { ); } - beforeEach(() => { + beforeEach(async () => { mockDir.clear(); + // This one is ESM-only + stripAnsi = await import('strip-ansi').then(m => m.default); + jest.resetAllMocks(); logger = mockServices.logger.mock(); actionRegistry = new TemplateActionRegistry(); diff --git a/plugins/search-backend-module-catalog/src/collators/DefaultCatalogCollatorFactory.ts b/plugins/search-backend-module-catalog/src/collators/DefaultCatalogCollatorFactory.ts index 1cef2e21df..f023efbf54 100644 --- a/plugins/search-backend-module-catalog/src/collators/DefaultCatalogCollatorFactory.ts +++ b/plugins/search-backend-module-catalog/src/collators/DefaultCatalogCollatorFactory.ts @@ -120,9 +120,11 @@ export class DefaultCatalogCollatorFactory implements DocumentCollatorFactory { resourceRef: stringifyEntityRef(entity), }, location: this.applyArgsToFormat(this.locationTemplate, { - namespace: entity.metadata.namespace || 'default', - kind: entity.kind, - name: entity.metadata.name, + namespace: encodeURIComponent( + entity.metadata.namespace || 'default', + ), + kind: encodeURIComponent(entity.kind), + name: encodeURIComponent(entity.metadata.name), }), }; } diff --git a/yarn.lock b/yarn.lock index 22a278f82a..33fc09a872 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11458,39 +11458,39 @@ __metadata: languageName: node linkType: hard -"@module-federation/bridge-react-webpack-plugin@npm:0.8.7": - version: 0.8.7 - resolution: "@module-federation/bridge-react-webpack-plugin@npm:0.8.7" +"@module-federation/bridge-react-webpack-plugin@npm:0.8.9": + version: 0.8.9 + resolution: "@module-federation/bridge-react-webpack-plugin@npm:0.8.9" dependencies: - "@module-federation/sdk": 0.8.7 + "@module-federation/sdk": 0.8.9 "@types/semver": 7.5.8 semver: 7.6.3 - checksum: de47a0bc01830138afafffcb093886dec175f1bee8f0c6ffe3d62744de2e34b379afdc50193520b9613a7b942a0729d31d204c1f2e04044e85613527b2cbeeb5 + checksum: f731e42ca14af20634d5a3b41af12f0969a4df5d48270206524fe5a79f1603235a397419c9ba0343735f5c345b1ac67f5c88172d56ed58953b20beb40bea37a0 languageName: node linkType: hard -"@module-federation/data-prefetch@npm:0.8.7": - version: 0.8.7 - resolution: "@module-federation/data-prefetch@npm:0.8.7" +"@module-federation/data-prefetch@npm:0.8.9": + version: 0.8.9 + resolution: "@module-federation/data-prefetch@npm:0.8.9" dependencies: - "@module-federation/runtime": 0.8.7 - "@module-federation/sdk": 0.8.7 + "@module-federation/runtime": 0.8.9 + "@module-federation/sdk": 0.8.9 fs-extra: 9.1.0 peerDependencies: react: ">=16.9.0" react-dom: ">=16.9.0" - checksum: 208e9cb08de88bfd6fad13dbc97f582d3be6c3a7245dd963e82e35810bfa441d07aeb53f2a3cdee693146d21d9fdf7b7df550c9e138c417db285f0bd285376e2 + checksum: 1b1a078eec13cc0f7e9ca2f3346326c1e4f19cc899e124fd0c351ad717094b2772995660cd622f01a2eb22ad795bb03369699a2dabe0e23ebc7f26109d104458 languageName: node linkType: hard -"@module-federation/dts-plugin@npm:0.8.7": - version: 0.8.7 - resolution: "@module-federation/dts-plugin@npm:0.8.7" +"@module-federation/dts-plugin@npm:0.8.9": + version: 0.8.9 + resolution: "@module-federation/dts-plugin@npm:0.8.9" dependencies: - "@module-federation/error-codes": 0.8.7 - "@module-federation/managers": 0.8.7 - "@module-federation/sdk": 0.8.7 - "@module-federation/third-party-dts-extractor": 0.8.7 + "@module-federation/error-codes": 0.8.9 + "@module-federation/managers": 0.8.9 + "@module-federation/sdk": 0.8.9 + "@module-federation/third-party-dts-extractor": 0.8.9 adm-zip: ^0.5.10 ansi-colors: ^4.1.3 axios: ^1.7.4 @@ -11509,24 +11509,24 @@ __metadata: peerDependenciesMeta: vue-tsc: optional: true - checksum: 82d0bccb7f1f159b57faa55d80a5ebff411439271b5dc60823558118a9106a6dc9195f5b0cd23a6018533903f7bf5ee0578cfcb3d6f212a8c448e93c53b1570e + checksum: e5e09c08ca186559a06f539272d2ff9005cc24ee69dd207c992ce9fcf8496640cbbdd022574e8bbdd4d21df886d71975215c3c5b2ae946defaa33f2156cd8bc8 languageName: node linkType: hard "@module-federation/enhanced@npm:^0.8.0": - version: 0.8.7 - resolution: "@module-federation/enhanced@npm:0.8.7" + version: 0.8.9 + resolution: "@module-federation/enhanced@npm:0.8.9" dependencies: - "@module-federation/bridge-react-webpack-plugin": 0.8.7 - "@module-federation/data-prefetch": 0.8.7 - "@module-federation/dts-plugin": 0.8.7 - "@module-federation/error-codes": 0.8.7 - "@module-federation/inject-external-runtime-core-plugin": 0.8.7 - "@module-federation/managers": 0.8.7 - "@module-federation/manifest": 0.8.7 - "@module-federation/rspack": 0.8.7 - "@module-federation/runtime-tools": 0.8.7 - "@module-federation/sdk": 0.8.7 + "@module-federation/bridge-react-webpack-plugin": 0.8.9 + "@module-federation/data-prefetch": 0.8.9 + "@module-federation/dts-plugin": 0.8.9 + "@module-federation/error-codes": 0.8.9 + "@module-federation/inject-external-runtime-core-plugin": 0.8.9 + "@module-federation/managers": 0.8.9 + "@module-federation/manifest": 0.8.9 + "@module-federation/rspack": 0.8.9 + "@module-federation/runtime-tools": 0.8.9 + "@module-federation/sdk": 0.8.9 btoa: ^1.2.1 upath: 2.0.1 peerDependencies: @@ -11540,61 +11540,61 @@ __metadata: optional: true webpack: optional: true - checksum: 6438b023bc56dea85dad5c5b840778bda75508976bba7d922ae8692a331b1b98041f3cd8f4a8a1189ea21ec2731d061b97b6644e2611437e90411efe90abda50 + checksum: 12b0cafc428bc65e06cbab407e369fe24739923e1555005b6954cebc379c8c2f8c10758081e479293687fe6c2dba437204f3f8fe7f83545a565a80484c322f82 languageName: node linkType: hard -"@module-federation/error-codes@npm:0.8.7": - version: 0.8.7 - resolution: "@module-federation/error-codes@npm:0.8.7" - checksum: e2d0448a85881d824fd2ca3ef9a04523d06eff4dd421962e8f86c34ef46a1cd48602d917eac3d57ecdc22ee5373232bb599a2ab66bd48a9b50b885d01f77053c +"@module-federation/error-codes@npm:0.8.9": + version: 0.8.9 + resolution: "@module-federation/error-codes@npm:0.8.9" + checksum: afeacc27a133e7867db4df6f8245b5d5c465fdd2faaec8608aea2b34bd922f38f23c02ed6dcd95303dddb2735b8fcc1bc137a08d470e605ca17807b705053578 languageName: node linkType: hard -"@module-federation/inject-external-runtime-core-plugin@npm:0.8.7": - version: 0.8.7 - resolution: "@module-federation/inject-external-runtime-core-plugin@npm:0.8.7" +"@module-federation/inject-external-runtime-core-plugin@npm:0.8.9": + version: 0.8.9 + resolution: "@module-federation/inject-external-runtime-core-plugin@npm:0.8.9" peerDependencies: - "@module-federation/runtime-tools": 0.8.7 - checksum: 28e4e447a79bb0de2ae270e0aabacaf1f2533a9d30d5cc445a81bddc796895c1f1fe895da611689d18912e52a0aba1b4b605bb2f54d517e8ce81bf44e5d8f7ac + "@module-federation/runtime-tools": 0.8.9 + checksum: 77d8361051d54345d346cf3a3d967ed71d9a0bf83182a101c0dfa5c1f6593219521e80a8fb048a4a47e40a3fc4cd552e388728c853fcdcd52bb8870c7e345069 languageName: node linkType: hard -"@module-federation/managers@npm:0.8.7": - version: 0.8.7 - resolution: "@module-federation/managers@npm:0.8.7" +"@module-federation/managers@npm:0.8.9": + version: 0.8.9 + resolution: "@module-federation/managers@npm:0.8.9" dependencies: - "@module-federation/sdk": 0.8.7 + "@module-federation/sdk": 0.8.9 find-pkg: 2.0.0 fs-extra: 9.1.0 - checksum: 1b38ba2dcde89a268b2f4de8fcdc4ddbf2efd5c1f1ba641e58be2f72aa62b27584c01b46261dc65747301a238f4fafbc22ac2c8ede071e11172318084a3a8522 + checksum: b477542ad7faf02c5fce1c50a5d49ed704aebf11091c942c8cfc8615cced35efd1f3180afd0d281959a6deae792b0f6910d2da9187a83dd7761042a56dfd27b4 languageName: node linkType: hard -"@module-federation/manifest@npm:0.8.7": - version: 0.8.7 - resolution: "@module-federation/manifest@npm:0.8.7" +"@module-federation/manifest@npm:0.8.9": + version: 0.8.9 + resolution: "@module-federation/manifest@npm:0.8.9" dependencies: - "@module-federation/dts-plugin": 0.8.7 - "@module-federation/managers": 0.8.7 - "@module-federation/sdk": 0.8.7 + "@module-federation/dts-plugin": 0.8.9 + "@module-federation/managers": 0.8.9 + "@module-federation/sdk": 0.8.9 chalk: 3.0.0 find-pkg: 2.0.0 - checksum: 702b721308d938758f1d57d5f0d3ef42bc2005e5441f6ccfdc92c7171fa6fdfcbd268f7054dc530dbf3d60b187ba394c4bfa38f3ba17baede5a492ef67b845e3 + checksum: a5221fccfc626250f5256a656b55d693077076fcb0b88d103b97c6e4d18f4656752734f5966ded0ac1e1a5a501629d179d23017857da640af0fe46ad2fecdb33 languageName: node linkType: hard -"@module-federation/rspack@npm:0.8.7": - version: 0.8.7 - resolution: "@module-federation/rspack@npm:0.8.7" +"@module-federation/rspack@npm:0.8.9": + version: 0.8.9 + resolution: "@module-federation/rspack@npm:0.8.9" dependencies: - "@module-federation/bridge-react-webpack-plugin": 0.8.7 - "@module-federation/dts-plugin": 0.8.7 - "@module-federation/inject-external-runtime-core-plugin": 0.8.7 - "@module-federation/managers": 0.8.7 - "@module-federation/manifest": 0.8.7 - "@module-federation/runtime-tools": 0.8.7 - "@module-federation/sdk": 0.8.7 + "@module-federation/bridge-react-webpack-plugin": 0.8.9 + "@module-federation/dts-plugin": 0.8.9 + "@module-federation/inject-external-runtime-core-plugin": 0.8.9 + "@module-federation/managers": 0.8.9 + "@module-federation/manifest": 0.8.9 + "@module-federation/runtime-tools": 0.8.9 + "@module-federation/sdk": 0.8.9 peerDependencies: "@rspack/core": ">=0.7" typescript: ^4.9.0 || ^5.0.0 @@ -11604,17 +11604,17 @@ __metadata: optional: true vue-tsc: optional: true - checksum: 933adfab6b62885e57da33cf453d450eb7f7ce983e54e2ae10ef4cb1b8c3ce333fec41428defd389b722d5323b355e9c374d42db00fd51faf2609fb8cdc29511 + checksum: 027b759e0c2b05f55b50835e3dad556ad4b889885fe64de3ee55d62850d3cff7fa746f517f8bc127be3a6d5ac4b6d78bc5c639ed378afe29d3917e4dfef67a8a languageName: node linkType: hard -"@module-federation/runtime-core@npm:0.6.15": - version: 0.6.15 - resolution: "@module-federation/runtime-core@npm:0.6.15" +"@module-federation/runtime-core@npm:0.6.17": + version: 0.6.17 + resolution: "@module-federation/runtime-core@npm:0.6.17" dependencies: - "@module-federation/error-codes": 0.8.7 - "@module-federation/sdk": 0.8.7 - checksum: 6a2acbdfd2fdb39dc9c8e6e0edaf371437405eb25800709ed94b2a3c5f36e3918d77da6bd17751635a9fe9ead6993aa343d4a9590d0797d28d9c017293782865 + "@module-federation/error-codes": 0.8.9 + "@module-federation/sdk": 0.8.9 + checksum: c7da28d11137b1d0925461d77660b4e431b2a6f0d66e851f34609c9d7acf515f2db9d993b6c2e993e3721753fb2df8fcd4d0bf62ee7b84cde9367b3bfce873e9 languageName: node linkType: hard @@ -11628,13 +11628,13 @@ __metadata: languageName: node linkType: hard -"@module-federation/runtime-tools@npm:0.8.7": - version: 0.8.7 - resolution: "@module-federation/runtime-tools@npm:0.8.7" +"@module-federation/runtime-tools@npm:0.8.9": + version: 0.8.9 + resolution: "@module-federation/runtime-tools@npm:0.8.9" dependencies: - "@module-federation/runtime": 0.8.7 - "@module-federation/webpack-bundler-runtime": 0.8.7 - checksum: 39ccdcdd6a178a3be2a574fe38ac063096a3c39ef5dc5f772120134205134ab0617c5d7f979dcf25f4bff18ed0538923544bc402231b19cf39d1912440473b8b + "@module-federation/runtime": 0.8.9 + "@module-federation/webpack-bundler-runtime": 0.8.9 + checksum: d59ba1620d7da5ee8ce2b7cebc4f813efe202b3af1bce9a395409a17f08633f864c0980744069c378896ef5f8bfd68574110873d42d615c36b51bd36e2e48c74 languageName: node linkType: hard @@ -11647,14 +11647,14 @@ __metadata: languageName: node linkType: hard -"@module-federation/runtime@npm:0.8.7": - version: 0.8.7 - resolution: "@module-federation/runtime@npm:0.8.7" +"@module-federation/runtime@npm:0.8.9": + version: 0.8.9 + resolution: "@module-federation/runtime@npm:0.8.9" dependencies: - "@module-federation/error-codes": 0.8.7 - "@module-federation/runtime-core": 0.6.15 - "@module-federation/sdk": 0.8.7 - checksum: 033dff2955290c29023593314de463744bee0eacce0bc3d0d1b9223ce2356f5d92554703e9f46066b269f24e3f16caa0cb675362d0ad88dfd9cae3b2a0e8b54c + "@module-federation/error-codes": 0.8.9 + "@module-federation/runtime-core": 0.6.17 + "@module-federation/sdk": 0.8.9 + checksum: 20495c029401431c43f5c5744d83d18c2f8ecafcc97cb960c3973119ec9d8ae313c5ceacbd1ae181331ccf51afb4185a2c8cde6e81ae372d7fa2d42f5af9de0b languageName: node linkType: hard @@ -11665,23 +11665,23 @@ __metadata: languageName: node linkType: hard -"@module-federation/sdk@npm:0.8.7": - version: 0.8.7 - resolution: "@module-federation/sdk@npm:0.8.7" +"@module-federation/sdk@npm:0.8.9": + version: 0.8.9 + resolution: "@module-federation/sdk@npm:0.8.9" dependencies: isomorphic-rslog: 0.0.7 - checksum: d78fffcf5413877047e05cc82bdf31810ad46f669a9393105280e4eae7fcaf80aaf29dd239ce6c7be61b59e31e93d6531a9afc6f89e4c9dcdb6a35e5cf01f408 + checksum: 74effd5aa86b3c9d7a08efc435a8b0bb244010de05b8e84a6269931bc72d59a467654b57c7870380502a1999bcd22c5c138b3558ab26663f366b6cec6b0fe225 languageName: node linkType: hard -"@module-federation/third-party-dts-extractor@npm:0.8.7": - version: 0.8.7 - resolution: "@module-federation/third-party-dts-extractor@npm:0.8.7" +"@module-federation/third-party-dts-extractor@npm:0.8.9": + version: 0.8.9 + resolution: "@module-federation/third-party-dts-extractor@npm:0.8.9" dependencies: find-pkg: 2.0.0 fs-extra: 9.1.0 resolve: 1.22.8 - checksum: 50ba3a7b507029291567c0334b257c6ba7ffc502627da958ff2247571a01d013d209143fb76557c9817aa62cd56a4ede69663866742063c97d2e14b3decf22ec + checksum: d5e67563ff80a107ba811477271d12bbc5c89869a13499d278961c1f13947f6fb85d9f5316fc9017be77692d876e8b7304136d4154a313023486b521548abafd languageName: node linkType: hard @@ -11695,13 +11695,13 @@ __metadata: languageName: node linkType: hard -"@module-federation/webpack-bundler-runtime@npm:0.8.7": - version: 0.8.7 - resolution: "@module-federation/webpack-bundler-runtime@npm:0.8.7" +"@module-federation/webpack-bundler-runtime@npm:0.8.9": + version: 0.8.9 + resolution: "@module-federation/webpack-bundler-runtime@npm:0.8.9" dependencies: - "@module-federation/runtime": 0.8.7 - "@module-federation/sdk": 0.8.7 - checksum: a32718e9ce51a8cc0c5a822b92507cfe087a75d9a979d12e1158018cb6abc469e5aee6c7d18bd0ded786600232056c57fc589101bda4f54b1708c6549ead7864 + "@module-federation/runtime": 0.8.9 + "@module-federation/sdk": 0.8.9 + checksum: 1ca36a54976207682cbac0e74eae7ceb7be102aff5008ed5b5f77a8aeab7e74198cd4c26a7c521ab6b38d4e7c31c86115901aa69753e76094012d171a1f79e4d languageName: node linkType: hard @@ -24242,10 +24242,10 @@ __metadata: languageName: node linkType: hard -"chalk@npm:~5.3.0": - version: 5.3.0 - resolution: "chalk@npm:5.3.0" - checksum: 623922e077b7d1e9dedaea6f8b9e9352921f8ae3afe739132e0e00c275971bdd331268183b2628cf4ab1727c45ea1f28d7e24ac23ce1db1eb653c414ca8a5a80 +"chalk@npm:~5.4.1": + version: 5.4.1 + resolution: "chalk@npm:5.4.1" + checksum: 0c656f30b782fed4d99198825c0860158901f449a6b12b818b0aabad27ec970389e7e8767d0e00762175b23620c812e70c4fd92c0210e55fc2d993638b74e86e languageName: node linkType: hard @@ -34362,10 +34362,10 @@ __metadata: linkType: hard "lint-staged@npm:^15.0.0": - version: 15.2.11 - resolution: "lint-staged@npm:15.2.11" + version: 15.3.0 + resolution: "lint-staged@npm:15.3.0" dependencies: - chalk: ~5.3.0 + chalk: ~5.4.1 commander: ~12.1.0 debug: ~4.4.0 execa: ~8.0.1 @@ -34377,7 +34377,7 @@ __metadata: yaml: ~2.6.1 bin: lint-staged: bin/lint-staged.js - checksum: 9668e67791fd73aa91cf9268641a5b41191b4221ac9e4d34b51bb10088a082548aa5984d9b38066c3832e41f5a01bbb36aa41d9a8c96bbc775301cb5c9071e13 + checksum: 9917a13c946cd812e4986a724602dfacf09baa4ddbbb4f08f7e59760181a7bf38459fcef54a396cc80b2af1655edae3e45410b23cfe1f70cf431662b2bf45624 languageName: node linkType: hard @@ -42977,8 +42977,8 @@ __metadata: linkType: hard "sort-package-json@npm:^2.8.0": - version: 2.12.0 - resolution: "sort-package-json@npm:2.12.0" + version: 2.14.0 + resolution: "sort-package-json@npm:2.14.0" dependencies: detect-indent: ^7.0.1 detect-newline: ^4.0.0 @@ -42990,7 +42990,7 @@ __metadata: tinyglobby: ^0.2.9 bin: sort-package-json: cli.js - checksum: c4bd25516b0e23c83a6c0f9ad418869a4b09675628e96f8c44b6b1f3458fbbd8a84d623ed63e24af620675977ab8a867e0f5356fc3cc4f9c7be85dcd3136d4ac + checksum: 37673522f2cc3d85ad85a477cc5b82a48726b0a91ea22225a8071afda4f80d7c50527a6c56791ec79b2c87328079b8e38af0819e7895102f291c693a5d92f913 languageName: node linkType: hard