Merge pull request #26389 from backstage/rugvip/new-inject

app: enable runtime templating and public path + config injection into index.html
This commit is contained in:
Patrik Oldsberg
2024-09-03 23:59:16 +03:00
committed by GitHub
20 changed files with 481 additions and 82 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-app-api': patch
---
The `defaultConfigLoader` now also reads configuration from scripts tags with `type="backstage.io/config"`. The tag is expected to contain a JSON-serialized array of `AppConfig` objects. If any of these script tags are present, the injected runtime configuration in the static assets will no longer be used.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/cli': patch
---
The app build process now outputs an additional `index.html.tmpl` file. This is an non-templated version of the `index.html` file, which can be used to delay templating until runtime.
The new `index.html.tmpl` file also sets a `backstage-public-path` meta tag to be templated at runtime. The meta tag is in turn picked up by the new `@backstage/cli/config/webpack-public-path.js` entry point script, which uses it to set the runtime public path of the Webpack bundle.
+11
View File
@@ -0,0 +1,11 @@
---
'@backstage/plugin-app-backend': patch
---
**BREAKING**: The app backend now supports the new `index.html.tmpl` output from `@backstage/cli`. If available, the `index.html` will be templated at runtime with the current configuration of the app backend.
This is marked as a breaking change because you must now supply the app build-time configuration to the backend. This change also affects the public path behavior, where it is no longer necessary to build the app with the correct public path upfront. You now only need to supply a correct `app.baseUrl` to the app backend plugin at runtime.
An effect that this change has is that the `index.html` will now contain and present the frontend configuration in an easily readable way, which can aid in debugging. This data was always available in the frontend, but it was injected and hidden in the static bundle.
This templating behavior is enabled by default, but it can be disabled by setting the `app.disableConfigInjection` configuration option to `true`.
@@ -0,0 +1,31 @@
/*
* 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.
*/
// This script is used to pick up and set the public path of the Webpack bundle
// at runtime. The meta tag is injected by the app build, but only present in
// the `index.html.tmpl` file. The runtime value of the meta tag is populated by
// the app backend, when it templates the final `index.html` file.
//
// This is needed for additional chunks to use the correct public path, and it
// is not possible to set the `__webpack_public_path__` variable outside of the
// build itself. The Webpack output also does not read any <base> tags or
// similar, this seems to be the only way to dynamically configure the public
// path at runtime.
const el = document.querySelector('meta[name="backstage-public-path"]');
const path = el?.getAttribute('content');
if (path) {
__webpack_public_path__ = path;
}
+1
View File
@@ -130,6 +130,7 @@
"pirates": "^4.0.6",
"postcss": "^8.1.0",
"process": "^0.11.10",
"raw-loader": "^4.0.2",
"react-dev-utils": "^12.0.0-next.60",
"react-refresh": "^0.14.0",
"recursive-readdir": "^2.2.2",
+18 -1
View File
@@ -198,6 +198,19 @@ export async function createConfig(
},
}),
);
plugins.push(
new HtmlWebpackPlugin({
meta: {
'backstage-app-mode': options?.appMode ?? 'public',
// This is added to be written in the later step, and finally read by the extra entry point
'backstage-public-path': '<%= publicPath %>/',
},
minify: false,
publicPath: '<%= publicPath %>',
filename: 'index.html.tmpl',
template: `raw-loader!${paths.targetHtml}`,
}),
);
}
if (options.moduleFederation) {
@@ -341,7 +354,11 @@ export async function createConfig(
},
devtool: isDev ? 'eval-cheap-module-source-map' : 'source-map',
context: paths.targetPath,
entry: [...(options.additionalEntryPoints ?? []), paths.targetEntry],
entry: [
require.resolve('@backstage/cli/config/webpack-public-path'),
...(options.additionalEntryPoints ?? []),
paths.targetEntry,
],
resolve: {
extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx', '.json', '.wasm'],
mainFields: ['browser', 'module', 'main'],
+2 -2
View File
@@ -14,12 +14,12 @@
* limitations under the License.
*/
import { ModuleOptions, WebpackPluginInstance } from 'webpack';
import { RuleSetRule, WebpackPluginInstance } from 'webpack';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import { svgrTemplate } from '../svgrTemplate';
type Transforms = {
loaders: ModuleOptions['rules'];
loaders: RuleSetRule[];
plugins: WebpackPluginInstance[];
};
@@ -14,7 +14,10 @@
* limitations under the License.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { defaultConfigLoaderSync } from './defaultConfigLoader';
import { ConfigReader } from '@backstage/config';
(process as any).env = { NODE_ENV: 'test' };
const anyEnv = process.env as any;
@@ -53,6 +56,45 @@ describe('defaultConfigLoaderSync', () => {
]);
});
it('loads config from script tags and ignore static config', () => {
anyEnv.APP_CONFIG = [];
render(
<script type="backstage.io/config">
{`[{"data":{"my":"config"},"context":"a"},{"data":{"my":"override-config"},"context":"b"}]`}
</script>,
);
const configs = (defaultConfigLoaderSync as any)('{"my":"runtime-config"}');
expect(configs).toEqual([
{ data: { my: 'config' }, context: 'a' },
{ data: { my: 'override-config' }, context: 'b' },
]);
expect(ConfigReader.fromConfigs(configs).get('my')).toBe('override-config');
});
it('loads config from all script tags in order', () => {
anyEnv.APP_CONFIG = [];
render(
<>
<script type="backstage.io/config">
{`[{"data":{"my":"config"},"context":"a"}]`}
</script>
<script type="backstage.io/config">
{`[{"data":{"my":"override-config"},"context":"b"}]`}
</script>
</>,
);
const configs = (defaultConfigLoaderSync as any)('{"my":"runtime-config"}');
expect(configs).toEqual([
{ data: { my: 'config' }, context: 'a' },
{ data: { my: 'override-config' }, context: 'b' },
]);
expect(ConfigReader.fromConfigs(configs).get('my')).toBe('override-config');
});
it('fails to load invalid missing config', () => {
expect(() => defaultConfigLoaderSync()).toThrow(
'No static configuration provided',
@@ -49,9 +49,36 @@ export function defaultConfigLoaderSync(
}
const configs = appConfig.slice() as unknown as AppConfig[];
// Avoiding this string also being replaced at runtime
if (
// Check if we have any config script tags, otherwise fall back to injected config
const configScripts = document.querySelectorAll(
'script[type="backstage.io/config"]',
);
if (configScripts.length > 0) {
for (const el of configScripts) {
try {
const content = el.textContent;
if (!content) {
throw new Error('tag is empty');
}
let data;
try {
data = JSON.parse(content);
} catch (error) {
throw new Error(`failed to parse config; ${error}`);
}
if (!Array.isArray(data)) {
throw new Error('data is not an array');
}
configs.push(...data);
} catch (error) {
throw new Error(
`Failed to load config from script tag, ${error.message}`,
);
}
}
} else if (
runtimeConfigJson !==
// Avoiding this string also being replaced at runtime
'__app_injected_runtime_config__'.toLocaleUpperCase('en-US')
) {
try {
@@ -0,0 +1,18 @@
/*
* 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 { injectConfig } from './injectConfig';
export { readFrontendConfig } from './readFrontendConfig';
@@ -0,0 +1,36 @@
/*
* Copyright 2020 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 { injectConfigIntoHtml } from './injectConfigIntoHtml';
import { injectConfigIntoStatic } from './injectConfigIntoStatic';
import { InjectOptions } from './types';
/**
* Injects configs into the app bundle, replacing any existing injected config.
* @internal
*/
export async function injectConfig(
options: InjectOptions,
): Promise<string | undefined> {
// In order to minimize the potential impact when rolling out the new config
// injection, we use both methods for a few releases. This allows the frontend
// app to be behind the backend by a version or two, but temporarily increases
// config injection overhead.
// TODO(Rugvip): After the 1.32 release we can stop calling the static injection if the HTML one is successful
await injectConfigIntoHtml(options);
return injectConfigIntoStatic(options);
}
@@ -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.
*/
import {
createMockDirectory,
mockServices,
} from '@backstage/backend-test-utils';
import { injectConfigIntoHtml } from './injectConfigIntoHtml';
describe('injectConfigIntoHtml', () => {
const mockDir = createMockDirectory();
const baseOptions = {
appConfigs: [],
rootDir: mockDir.path,
staticDir: 'ignored',
logger: mockServices.logger.mock(),
};
beforeEach(() => {
mockDir.clear();
});
it('should template html', async () => {
mockDir.setContent({
'index.html.tmpl': "<html><%= config.getNumber('x') %></html>",
});
await injectConfigIntoHtml({
...baseOptions,
appConfigs: [{ context: 'mock', data: { x: 1 } }],
});
expect(mockDir.content()).toMatchObject({
'index.html': '<html>1</html>',
});
});
it('should inject config', async () => {
mockDir.setContent({
'index.html.tmpl': '<html><head></head></html>',
});
await injectConfigIntoHtml({
...baseOptions,
appConfigs: [{ context: 'mock', data: { x: 1 } }],
});
expect(mockDir.content()).toMatchObject({
'index.html': `<html><head>
<script type="backstage.io/config">
[
{
"context": "mock",
"data": {
"x": 1
}
}
]
</script>
</head></html>`,
});
});
});
@@ -0,0 +1,78 @@
/*
* 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 fs from 'fs-extra';
import { resolve as resolvePath } from 'path';
import { InjectOptions } from './types';
import compileTemplate from 'lodash/template';
import { Config, ConfigReader } from '@backstage/config';
const HTML_TEMPLATE_NAME = 'index.html.tmpl';
/** @internal */
export async function injectConfigIntoHtml(
options: InjectOptions,
): Promise<boolean> {
const { rootDir, appConfigs } = options;
const templatePath = resolvePath(rootDir, HTML_TEMPLATE_NAME);
if (!(await fs.exists(templatePath))) {
return false;
}
const templateContent = await fs.readFile(
resolvePath(rootDir, HTML_TEMPLATE_NAME),
'utf8',
);
const config = ConfigReader.fromConfigs(appConfigs);
const templateSource = compileTemplate(templateContent, {
interpolate: /<%=([\s\S]+?)%>/g,
});
const publicPath = resolvePublicPath(config);
const indexHtmlContent = templateSource({
config,
publicPath,
});
const indexHtmlContentWithConfig = indexHtmlContent.replace(
'</head>',
`
<script type="backstage.io/config">
${JSON.stringify(appConfigs, null, 2)}
</script>
</head>`,
);
await fs.writeFile(
resolvePath(rootDir, 'index.html'),
indexHtmlContentWithConfig,
'utf8',
);
return true;
}
export function resolvePublicPath(config: Config) {
const baseUrl = new URL(
config.getOptionalString('app.baseUrl') ?? '/',
'http://localhost:7007',
);
return baseUrl.pathname.replace(/\/+$/, '');
}
@@ -18,13 +18,14 @@ import {
createMockDirectory,
mockServices,
} from '@backstage/backend-test-utils';
import { injectConfig } from './config';
import { injectConfigIntoStatic } from './injectConfigIntoStatic';
describe('injectConfig', () => {
describe('injectConfigIntoStatic', () => {
const mockDir = createMockDirectory();
const baseOptions = {
appConfigs: [],
rootDir: 'ignored',
staticDir: mockDir.path,
logger: mockServices.logger.mock(),
};
@@ -42,7 +43,7 @@ describe('injectConfig', () => {
'main.js': '"__APP_INJECTED_RUNTIME_CONFIG__"',
});
await injectConfig(baseOptions);
await injectConfigIntoStatic(baseOptions);
expect(mockDir.content()).toEqual({
'main.js': '/*__APP_INJECTED_CONFIG_MARKER__*/"[]"/*__INJECTED_END__*/',
@@ -55,7 +56,7 @@ describe('injectConfig', () => {
'({a:"__APP_INJECTED_RUNTIME_CONFIG__",b:"__APP_INJECTED_RUNTIME_CONFIG__"})',
});
await injectConfig(baseOptions);
await injectConfigIntoStatic(baseOptions);
expect(mockDir.content()).toEqual({
'main.js':
@@ -71,7 +72,7 @@ describe('injectConfig', () => {
'after.js': 'NO_PLACEHOLDER_HERE',
});
await injectConfig({
await injectConfigIntoStatic({
...baseOptions,
appConfigs: [{ data: { x: 0 }, context: 'test' }],
});
@@ -90,7 +91,7 @@ describe('injectConfig', () => {
'main.js': 'JSON.parse("__APP_INJECTED_RUNTIME_CONFIG__")',
});
await injectConfig({
await injectConfigIntoStatic({
...baseOptions,
appConfigs: [{ data: { x: 0 }, context: 'test' }],
});
@@ -100,7 +101,7 @@ describe('injectConfig', () => {
'JSON.parse(/*__APP_INJECTED_CONFIG_MARKER__*/"[{\\"data\\":{\\"x\\":0},\\"context\\":\\"test\\"}]"/*__INJECTED_END__*/)',
});
await injectConfig({
await injectConfigIntoStatic({
...baseOptions,
appConfigs: [{ data: { x: 1, y: 2 }, context: 'test' }],
});
@@ -117,7 +118,7 @@ describe('injectConfig', () => {
'({ a: JSON.parse("__APP_INJECTED_RUNTIME_CONFIG__"), b: JSON.parse("__APP_INJECTED_RUNTIME_CONFIG__") })',
});
await injectConfig({
await injectConfigIntoStatic({
...baseOptions,
appConfigs: [{ data: { x: 0 }, context: 'test' }],
});
@@ -127,7 +128,7 @@ describe('injectConfig', () => {
'({ a: JSON.parse(/*__APP_INJECTED_CONFIG_MARKER__*/"[{\\"data\\":{\\"x\\":0},\\"context\\":\\"test\\"}]"/*__INJECTED_END__*/), b: JSON.parse(/*__APP_INJECTED_CONFIG_MARKER__*/"[{\\"data\\":{\\"x\\":0},\\"context\\":\\"test\\"}]"/*__INJECTED_END__*/) })',
});
await injectConfig({
await injectConfigIntoStatic({
...baseOptions,
appConfigs: [{ data: { x: 1, y: 2 }, context: 'test' }],
});
@@ -0,0 +1,61 @@
/*
* Copyright 2020 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 fs from 'fs-extra';
import { resolve as resolvePath } from 'path';
import { InjectOptions } from './types';
/**
* Injects configs into the app bundle, replacing any existing injected config.
*/
export async function injectConfigIntoStatic(
options: InjectOptions,
): Promise<string | undefined> {
const { staticDir, logger, appConfigs } = options;
const files = await fs.readdir(staticDir);
const jsFiles = files.filter(file => file.endsWith('.js'));
const escapedData = JSON.stringify(appConfigs).replace(/("|'|\\)/g, '\\$1');
const injected = `/*__APP_INJECTED_CONFIG_MARKER__*/"${escapedData}"/*__INJECTED_END__*/`;
for (const jsFile of jsFiles) {
const path = resolvePath(staticDir, jsFile);
const content = await fs.readFile(path, 'utf8');
if (content.includes('__APP_INJECTED_RUNTIME_CONFIG__')) {
logger.info(`Injecting env config into ${jsFile}`);
const newContent = content.replaceAll(
'"__APP_INJECTED_RUNTIME_CONFIG__"',
injected,
);
await fs.writeFile(path, newContent, 'utf8');
return path;
} else if (content.includes('__APP_INJECTED_CONFIG_MARKER__')) {
logger.info(`Replacing injected env config in ${jsFile}`);
const newContent = content.replaceAll(
/\/\*__APP_INJECTED_CONFIG_MARKER__\*\/.*?\/\*__INJECTED_END__\*\//g,
injected,
);
await fs.writeFile(path, newContent, 'utf8');
return path;
}
}
logger.info('Env config not injected');
return undefined;
}
@@ -23,69 +23,17 @@ import {
loadConfigSchema,
readEnvConfig,
} from '@backstage/config-loader';
import { LoggerService } from '@backstage/backend-plugin-api';
type InjectOptions = {
appConfigs: AppConfig[];
// Directory of the static JS files to search for file to inject
staticDir: string;
logger: LoggerService;
};
/**
* Injects configs into the app bundle, replacing any existing injected config.
*/
export async function injectConfig(
options: InjectOptions,
): Promise<string | undefined> {
const { staticDir, logger, appConfigs } = options;
const files = await fs.readdir(staticDir);
const jsFiles = files.filter(file => file.endsWith('.js'));
const escapedData = JSON.stringify(appConfigs).replace(/("|'|\\)/g, '\\$1');
const injected = `/*__APP_INJECTED_CONFIG_MARKER__*/"${escapedData}"/*__INJECTED_END__*/`;
for (const jsFile of jsFiles) {
const path = resolvePath(staticDir, jsFile);
const content = await fs.readFile(path, 'utf8');
if (content.includes('__APP_INJECTED_RUNTIME_CONFIG__')) {
logger.info(`Injecting env config into ${jsFile}`);
const newContent = content.replaceAll(
'"__APP_INJECTED_RUNTIME_CONFIG__"',
injected,
);
await fs.writeFile(path, newContent, 'utf8');
return path;
} else if (content.includes('__APP_INJECTED_CONFIG_MARKER__')) {
logger.info(`Replacing injected env config in ${jsFile}`);
const newContent = content.replaceAll(
/\/\*__APP_INJECTED_CONFIG_MARKER__\*\/.*?\/\*__INJECTED_END__\*\//g,
injected,
);
await fs.writeFile(path, newContent, 'utf8');
return path;
}
}
logger.info('Env config not injected');
return undefined;
}
type ReadOptions = {
env: { [name: string]: string | undefined };
appDistDir: string;
config: Config;
schema?: ConfigSchema;
};
/**
* Read config from environment and process the backend config using the
* schema that is embedded in the frontend build.
*/
export async function readConfigs(options: ReadOptions): Promise<AppConfig[]> {
export async function readFrontendConfig(options: {
env: { [name: string]: string | undefined };
appDistDir: string;
config: Config;
schema?: ConfigSchema;
}): Promise<AppConfig[]> {
const { env, appDistDir, config } = options;
const appConfigs = readEnvConfig(env);
@@ -0,0 +1,27 @@
/*
* 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 { LoggerService } from '@backstage/backend-plugin-api';
import { AppConfig } from '@backstage/config';
export interface InjectOptions {
appConfigs: AppConfig[];
/** Directory of the public web content */
rootDir: string;
/** Directory of the static JS files to search for file to inject */
staticDir: string;
logger: LoggerService;
}
@@ -25,7 +25,7 @@ import { mockServices } from '@backstage/backend-test-utils';
jest.mock('../lib/config', () => ({
injectConfig: jest.fn(),
readConfigs: jest.fn(),
readFrontendConfig: jest.fn(),
}));
global.__non_webpack_require__ = {
@@ -120,11 +120,13 @@ describe('createRouter with static fallback handler', () => {
describe('createRouter config schema test', () => {
const libConfigs = require('../lib/config');
const libConfigsActual = jest.requireActual('../lib/config');
const readConfigsMock: jest.Mock = libConfigs.readConfigs;
const readFrontendConfigMock: jest.Mock = libConfigs.readFrontendConfig;
beforeEach(() => {
jest.resetAllMocks();
readConfigsMock.mockImplementation(libConfigsActual.readConfigs);
readFrontendConfigMock.mockImplementation(
libConfigsActual.readFrontendConfig,
);
});
it('uses an external schema', async () => {
@@ -155,7 +157,7 @@ describe('createRouter config schema test', () => {
}),
});
const results = readConfigsMock.mock.results;
const results = readFrontendConfigMock.mock.results;
expect(results.length).toBe(1);
const mockedResult = results[0];
@@ -177,7 +179,7 @@ describe('createRouter config schema test', () => {
appPackageName: 'example-app',
});
const results = readConfigsMock.mock.results;
const results = readFrontendConfigMock.mock.results;
expect(results.length).toBe(1);
const mockedResult = results[0];
+4 -3
View File
@@ -26,7 +26,6 @@ import express from 'express';
import Router from 'express-promise-router';
import fs from 'fs-extra';
import { resolve as resolvePath } from 'path';
import { injectConfig, readConfigs } from '../lib/config';
import {
createStaticAssetMiddleware,
findStaticAssets,
@@ -44,6 +43,7 @@ import {
LoggerService,
} from '@backstage/backend-plugin-api';
import { AuthenticationError } from '@backstage/errors';
import { injectConfig, readFrontendConfig } from '../lib/config';
// express uses mime v1 while we only have types for mime v2
type Mime = { lookup(arg0: string): string };
@@ -147,7 +147,7 @@ export async function createRouter(
const appConfigs = disableConfigInjection
? undefined
: await readConfigs({
: await readFrontendConfig({
config,
appDistDir,
env: process.env,
@@ -266,7 +266,8 @@ async function createEntryPointRouter({
const staticDir = resolvePath(rootDir, 'static');
const injectedConfigPath =
appConfigs && (await injectConfig({ appConfigs, logger, staticDir }));
appConfigs &&
(await injectConfig({ appConfigs, logger, rootDir, staticDir }));
const router = Router();
+15 -2
View File
@@ -4008,6 +4008,7 @@ __metadata:
pirates: ^4.0.6
postcss: ^8.1.0
process: ^0.11.10
raw-loader: ^4.0.2
react-dev-utils: ^12.0.0-next.60
react-refresh: ^0.14.0
recursive-readdir: ^2.2.2
@@ -32626,7 +32627,7 @@ __metadata:
languageName: node
linkType: hard
"loader-utils@npm:^2.0.4":
"loader-utils@npm:^2.0.0, loader-utils@npm:^2.0.4":
version: 2.0.4
resolution: "loader-utils@npm:2.0.4"
dependencies:
@@ -38204,6 +38205,18 @@ __metadata:
languageName: node
linkType: hard
"raw-loader@npm:^4.0.2":
version: 4.0.2
resolution: "raw-loader@npm:4.0.2"
dependencies:
loader-utils: ^2.0.0
schema-utils: ^3.0.0
peerDependencies:
webpack: ^4.0.0 || ^5.0.0
checksum: 51cc1b0d0e8c37c4336b5318f3b2c9c51d6998ad6f56ea09612afcfefc9c1f596341309e934a744ae907177f28efc9f1654eacd62151e82853fcc6d37450e795
languageName: node
linkType: hard
"rc-progress@npm:3.5.1":
version: 3.5.1
resolution: "rc-progress@npm:3.5.1"
@@ -40227,7 +40240,7 @@ __metadata:
languageName: node
linkType: hard
"schema-utils@npm:^3.1.1, schema-utils@npm:^3.2.0":
"schema-utils@npm:^3.0.0, schema-utils@npm:^3.1.1, schema-utils@npm:^3.2.0":
version: 3.3.0
resolution: "schema-utils@npm:3.3.0"
dependencies: