Merge remote-tracking branch 'origin/master' into blam/break-up-scaffolder
* origin/master: (55 commits) backend-app-api: switch plugin log label pluginId->plugin backend-app-api: create a labelled logger for rootHttpRouter backend-app-api: remove startHttpServer fix(deps): update dependency @rjsf/utils to v5.0.0-beta.16 backend-app-api: fix infinite loop in http server stop remove autoformatting changes remove dev file use index for key in react fragment Minor grammar fix to CONTRIBUTING.md backend-app-api: explicit request handler types for MiddlewareFactory added changesets for the root API refactor and move from backend-common backend-app-api: update API report + fixes add highlighting of legend item docs(docs/features/software-templates/writing-custom-field-extensions.md): change "kehab" to "kebab". backend-app-api: move http dir out of lib backend-common: reimplement service builder using backend-app-api backend-app-api: move cors and helmet options reader to http folder address review comments fix picky cli-report fix logic ... Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/backend-defaults': patch
|
||||
'@backstage/backend-app-api': patch
|
||||
---
|
||||
|
||||
Use new `ServiceFactoryOrFunction` type.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@techdocs/cli': minor
|
||||
---
|
||||
|
||||
Add `--preview-app-bundle-path` and `--preview-app-port` options to the `serve` command enabling previewing with apps other than the provided one
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-plugin-api': patch
|
||||
---
|
||||
|
||||
Added `ServiceFactoryOrFunction` type, for use when either a `ServiceFactory` or `() => ServiceFactory` can be used.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-app-api': patch
|
||||
---
|
||||
|
||||
Moved over implementation of the root HTTP service from `@backstage/backend-common`, and replaced the `middleware` option with a `configure` callback option.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-tasks': patch
|
||||
---
|
||||
|
||||
Minor internal refactor to avoid import cycle issue.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-tech-radar': minor
|
||||
---
|
||||
|
||||
Add highlighting of legend item and show bubble on hover within the Tech Radar
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
This patch adds changes to provide examples alongside scaffolder task actions.
|
||||
|
||||
The `createTemplateAction` function now takes a list of examples e.g.
|
||||
|
||||
```typescript
|
||||
const actionExamples = [
|
||||
{
|
||||
description: 'Example 1',
|
||||
example: yaml.stringify({
|
||||
steps: [
|
||||
{
|
||||
action: 'test:action',
|
||||
id: 'test',
|
||||
input: {
|
||||
input1: 'value',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export function createTestAction() {
|
||||
return createTemplateAction({
|
||||
id: 'test:action',
|
||||
examples: [
|
||||
{
|
||||
description: 'Example 1',
|
||||
examples: actionExamples
|
||||
}
|
||||
],
|
||||
...,
|
||||
});
|
||||
```
|
||||
|
||||
These examples can be retrieved later from the api.
|
||||
|
||||
```bash
|
||||
curl http://localhost:7007/api/scaffolder/v2/actions
|
||||
```
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "test:action",
|
||||
"examples": [
|
||||
{
|
||||
"description": "Example 1",
|
||||
"example": "steps:\n - action: test:action\n id: test\n input:\n input1: value\n"
|
||||
}
|
||||
],
|
||||
"schema": {
|
||||
"input": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input1": {
|
||||
"title": "Input 1",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-common': patch
|
||||
---
|
||||
|
||||
Refactor to rely on `@backstage/backend-app-api` for the implementation of `createServiceBuilder`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-app-api': patch
|
||||
---
|
||||
|
||||
Tweaked the plugin logger to use `plugin` as the label for the plugin ID, rather than `pluginId`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder': patch
|
||||
---
|
||||
|
||||
Show action example yaml on the scaffolder actions documentation page.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend-module-incremental-ingestion': patch
|
||||
'@backstage/plugin-catalog-backend-module-bitbucket-server': patch
|
||||
'@backstage/plugin-catalog-backend-module-bitbucket-cloud': patch
|
||||
'@backstage/plugin-catalog-backend-module-msgraph': patch
|
||||
'@backstage/plugin-catalog-backend-module-gerrit': patch
|
||||
'@backstage/plugin-catalog-backend-module-github': patch
|
||||
'@backstage/plugin-catalog-backend-module-gitlab': patch
|
||||
'@backstage/plugin-events-backend-module-aws-sqs': patch
|
||||
'@backstage/plugin-catalog-backend-module-azure': patch
|
||||
'@backstage/plugin-catalog-backend-module-ldap': patch
|
||||
'@backstage/plugin-catalog-backend-module-aws': patch
|
||||
'@techdocs/cli': patch
|
||||
---
|
||||
|
||||
Provide context for logged errors.
|
||||
+1
-1
@@ -142,7 +142,7 @@ Changesets **are** needed for new packages, as that is what triggers the package
|
||||
6. Push the commit with your changeset to the branch associated with your PR
|
||||
7. Accept our gratitude for making the release process easier on the maintainers
|
||||
|
||||
For more information, checkout [adding a changeset](https://github.com/atlassian/changesets/blob/master/docs/adding-a-changeset.md) documentation in the changesets repository.
|
||||
For more information, check out [adding a changeset](https://github.com/atlassian/changesets/blob/master/docs/adding-a-changeset.md) documentation in the changesets repository.
|
||||
|
||||
## Merging to Master
|
||||
|
||||
|
||||
@@ -23,18 +23,19 @@ the `Scaffolder` frontend plugin in your own `App.tsx`.
|
||||
|
||||
You can create your own Field Extension by using the
|
||||
[`createScaffolderFieldExtension`](https://backstage.io/docs/reference/plugin-scaffolder.createscaffolderfieldextension)
|
||||
`API` like below:
|
||||
`API` like below.
|
||||
|
||||
As an example, we will create a component that validates whether a string is in the `Kebab-case` pattern:
|
||||
|
||||
```tsx
|
||||
//packages/app/src/scaffolder/MyCustomExtension/MyCustomExtension.tsx
|
||||
//packages/app/src/scaffolder/ValidateKebabCase/ValidateKebabCaseExtension.tsx
|
||||
import React from 'react';
|
||||
import { FieldProps, FieldValidation } from '@rjsf/core';
|
||||
import FormControl from '@material-ui/core/FormControl';
|
||||
import { KubernetesValidatorFunctions } from '@backstage/catalog-model';
|
||||
/*
|
||||
This is the actual component that will get rendered in the form
|
||||
*/
|
||||
export const MyCustomExtension = ({
|
||||
export const ValidateKebabCaseExtension = ({
|
||||
onChange,
|
||||
rawErrors,
|
||||
required,
|
||||
@@ -45,8 +46,17 @@ export const MyCustomExtension = ({
|
||||
margin="normal"
|
||||
required={required}
|
||||
error={rawErrors?.length > 0 && !formData}
|
||||
onChange={onChange}
|
||||
/>
|
||||
>
|
||||
<InputLabel htmlFor="validateName">Name</InputLabel>
|
||||
<Input
|
||||
id="validateName"
|
||||
aria-describedby="entityName"
|
||||
onChange={e => onChange(e.target?.value)}
|
||||
/>
|
||||
<FormHelperText id="entityName">
|
||||
Use only letters, numbers, hyphens and underscores
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -55,20 +65,22 @@ export const MyCustomExtension = ({
|
||||
You will get the value from the `onChange` handler before as the value here to make sure that the types are aligned\
|
||||
*/
|
||||
|
||||
export const myCustomValidation = (
|
||||
export const validateKebabCaseValidation = (
|
||||
value: string,
|
||||
validation: FieldValidation,
|
||||
) => {
|
||||
if (!KubernetesValidatorFunctions.isValidObjectName(value)) {
|
||||
const kebabCase = /^[a-z0-9-_]+$/g.test(value);
|
||||
|
||||
if (kebabCase === false) {
|
||||
validation.addError(
|
||||
'must start and end with an alphanumeric character, and contain only alphanumeric characters, hyphens, underscores, and periods. Maximum length is 63 characters.',
|
||||
`Only use letters, numbers, hyphen ("-") and underscore ("_").`,
|
||||
);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
```tsx
|
||||
// packages/app/src/scaffolder/MyCustomExtension/extensions.ts
|
||||
// packages/app/src/scaffolder/ValidateKebabCase/extensions.ts
|
||||
|
||||
/*
|
||||
This is where the magic happens and creates the custom field extension.
|
||||
@@ -81,21 +93,24 @@ import {
|
||||
scaffolderPlugin,
|
||||
createScaffolderFieldExtension,
|
||||
} from '@backstage/plugin-scaffolder';
|
||||
import { MyCustomExtension, myCustomValidation } from './MyCustomExtension';
|
||||
import {
|
||||
ValidateKebabCase,
|
||||
validateKebabCaseValidation,
|
||||
} from './ValidateKebabCase';
|
||||
|
||||
export const MyCustomFieldExtension = scaffolderPlugin.provide(
|
||||
export const ValidateKebabCaseFieldExtension = scaffolderPlugin.provide(
|
||||
createScaffolderFieldExtension({
|
||||
name: 'MyCustomExtension',
|
||||
component: MyCustomExtension,
|
||||
validation: myCustomValidation,
|
||||
name: 'ValidateKebabCase',
|
||||
component: ValidateKebabCase,
|
||||
validation: validateKebabCaseValidation,
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
```tsx
|
||||
// packages/app/src/scaffolder/MyCustomExtension/index.ts
|
||||
// packages/app/src/scaffolder/ValidateKebabCase/index.ts
|
||||
|
||||
export { MyCustomFieldExtension } from './extensions';
|
||||
export { ValidateKebabCaseFieldExtension } from './extensions';
|
||||
```
|
||||
|
||||
Once all these files are in place, you then need to provide your custom
|
||||
@@ -117,7 +132,7 @@ const routes = (
|
||||
Should look something like this instead:
|
||||
|
||||
```tsx
|
||||
import { MyCustomFieldExtension } from './scaffolder/MyCustomExtension';
|
||||
import { ValidateKebabCaseFieldExtension } from './scaffolder/ValidateKebabCase';
|
||||
import { ScaffolderFieldExtensions } from '@backstage/plugin-scaffolder';
|
||||
|
||||
const routes = (
|
||||
@@ -125,7 +140,7 @@ const routes = (
|
||||
...
|
||||
<Route path="/create" element={<ScaffolderPage />}>
|
||||
<ScaffolderFieldExtensions>
|
||||
<MyCustomFieldExtension />
|
||||
<ValidateKebabCaseFieldExtension />
|
||||
</ScaffolderFieldExtensions>
|
||||
</Route>
|
||||
...
|
||||
@@ -158,7 +173,9 @@ spec:
|
||||
title: Name
|
||||
type: string
|
||||
description: My custom name for the component
|
||||
ui:field: MyCustomExtension
|
||||
ui:field: ValidateKebabCaseExtension
|
||||
steps:
|
||||
[...]
|
||||
```
|
||||
|
||||
## Access Data from other Fields
|
||||
|
||||
@@ -70,6 +70,11 @@ a Backstage app server on port 3000. The Backstage app has a custom TechDocs API
|
||||
implementation, which uses the MkDocs preview server as a proxy to fetch the
|
||||
generated documentation files and assets.
|
||||
|
||||
Backstage instances might differ from the provided preview app in appearance and
|
||||
behavior. To preview documentation with a different app, use
|
||||
`--preview-app-bundle-path` with a path to the bundle of the app to use instead.
|
||||
Typically, a `dist` or `build` directory.
|
||||
|
||||
NOTE: When using a custom `techdocs` docker image, make sure the entry point is
|
||||
also `ENTRYPOINT ["mkdocs"]` or override with `--docker-entrypoint`.
|
||||
|
||||
@@ -81,14 +86,17 @@ Usage: techdocs-cli serve [options]
|
||||
Serve a documentation project locally in a Backstage app-like environment
|
||||
|
||||
Options:
|
||||
-i, --docker-image <DOCKER_IMAGE> The mkdocs docker container to use (default: "spotify/techdocs")
|
||||
--docker-entrypoint <DOCKER_ENTRYPOINT> Override the image entrypoint
|
||||
--docker-option <DOCKER_OPTION...> Extra options to pass to the docker run command, e.g. "--add-host=internal.host:192.168.11.12"
|
||||
(can be added multiple times).
|
||||
--no-docker Do not use Docker, use MkDocs executable in current user environment.
|
||||
--mkdocs-port <PORT> Port for MkDocs server to use (default: "8000")
|
||||
-v --verbose Enable verbose output. (default: false)
|
||||
-h, --help display help for command
|
||||
-i, --docker-image <DOCKER_IMAGE> The mkdocs docker container to use (default: "spotify/techdocs")
|
||||
--docker-entrypoint <DOCKER_ENTRYPOINT> Override the image entrypoint
|
||||
--docker-option <DOCKER_OPTION...> Extra options to pass to the docker run command, e.g. "--add-host=internal.host:192.168.11.12"
|
||||
(can be added multiple times).
|
||||
--no-docker Do not use Docker, use MkDocs executable in current user environment.
|
||||
--mkdocs-port <PORT> Port for MkDocs server to use (default: "8000")
|
||||
--preview-app-bundle-path <PATH_TO_BUNDLE> Preview documentation using a web app other than the included one.
|
||||
--preview-app-port <PORT> Port where the preview will be served.
|
||||
Can only be used with "--preview-app-bundle-path". (default: "3000")
|
||||
-v --verbose Enable verbose output. (default: false)
|
||||
-h, --help display help for command
|
||||
```
|
||||
|
||||
### Generate TechDocs site from a documentation project
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: Toolbox
|
||||
author: drodil
|
||||
authorUrl: https://github.com/drodil
|
||||
category: Tools
|
||||
description: Development related tools within Backstage
|
||||
documentation: https://github.com/drodil/backstage-plugin-toolbox
|
||||
iconUrl: img/toolbox-logo.png
|
||||
npmPackageName: '@drodil/backstage-plugin-toolbox'
|
||||
addedDate: '2023-01-09'
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
@@ -3,10 +3,17 @@
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
/// <reference types="node" />
|
||||
|
||||
import { BackendFeature } from '@backstage/backend-plugin-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import { ConfigService } from '@backstage/backend-plugin-api';
|
||||
import { CorsOptions } from 'cors';
|
||||
import { ErrorRequestHandler } from 'express';
|
||||
import { Express as Express_2 } from 'express';
|
||||
import { ExtensionPoint } from '@backstage/backend-plugin-api';
|
||||
import { Handler } from 'express';
|
||||
import { HelmetOptions } from 'helmet';
|
||||
import * as http from 'http';
|
||||
import { HttpRouterService } from '@backstage/backend-plugin-api';
|
||||
import { LifecycleService } from '@backstage/backend-plugin-api';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
@@ -14,11 +21,14 @@ import { PermissionsService } from '@backstage/backend-plugin-api';
|
||||
import { PluginCacheManager } from '@backstage/backend-common';
|
||||
import { PluginDatabaseManager } from '@backstage/backend-common';
|
||||
import { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
import { RequestHandler } from 'express';
|
||||
import { RequestListener } from 'http';
|
||||
import { RootHttpRouterService } from '@backstage/backend-plugin-api';
|
||||
import { RootLifecycleService } from '@backstage/backend-plugin-api';
|
||||
import { RootLoggerService } from '@backstage/backend-plugin-api';
|
||||
import { SchedulerService } from '@backstage/backend-plugin-api';
|
||||
import { ServiceFactory } from '@backstage/backend-plugin-api';
|
||||
import { ServiceFactoryOrFunction } from '@backstage/backend-plugin-api';
|
||||
import { ServiceRef } from '@backstage/backend-plugin-api';
|
||||
import { TokenManagerService } from '@backstage/backend-plugin-api';
|
||||
import { UrlReader } from '@backstage/backend-common';
|
||||
@@ -43,6 +53,15 @@ export const configFactory: (
|
||||
options?: undefined,
|
||||
) => ServiceFactory<ConfigService>;
|
||||
|
||||
// @public
|
||||
export function createHttpServer(
|
||||
listener: RequestListener,
|
||||
options: HttpServerOptions,
|
||||
deps: {
|
||||
logger: LoggerService;
|
||||
},
|
||||
): Promise<ExtendedHttpServer>;
|
||||
|
||||
// @public (undocumented)
|
||||
export function createSpecializedBackend(
|
||||
options: CreateSpecializedBackendOptions,
|
||||
@@ -51,7 +70,7 @@ export function createSpecializedBackend(
|
||||
// @public (undocumented)
|
||||
export interface CreateSpecializedBackendOptions {
|
||||
// (undocumented)
|
||||
services: (ServiceFactory | (() => ServiceFactory))[];
|
||||
services: ServiceFactoryOrFunction[];
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
@@ -64,6 +83,16 @@ export const discoveryFactory: (
|
||||
options?: undefined,
|
||||
) => ServiceFactory<PluginEndpointDiscovery>;
|
||||
|
||||
// @public
|
||||
export interface ExtendedHttpServer extends http.Server {
|
||||
// (undocumented)
|
||||
port(): number;
|
||||
// (undocumented)
|
||||
start(): Promise<void>;
|
||||
// (undocumented)
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const httpRouterFactory: (
|
||||
options?: HttpRouterFactoryOptions | undefined,
|
||||
@@ -74,6 +103,29 @@ export type HttpRouterFactoryOptions = {
|
||||
getPath(pluginId: string): string;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type HttpServerCertificateOptions =
|
||||
| {
|
||||
type: 'plain';
|
||||
key: string;
|
||||
cert: string;
|
||||
}
|
||||
| {
|
||||
type: 'generated';
|
||||
hostname: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type HttpServerOptions = {
|
||||
listen: {
|
||||
port: number;
|
||||
host: string;
|
||||
};
|
||||
https?: {
|
||||
certificate: HttpServerCertificateOptions;
|
||||
};
|
||||
};
|
||||
|
||||
// @public
|
||||
export const lifecycleFactory: (
|
||||
options?: undefined,
|
||||
@@ -84,11 +136,61 @@ export const loggerFactory: (
|
||||
options?: undefined,
|
||||
) => ServiceFactory<LoggerService>;
|
||||
|
||||
// @public
|
||||
export class MiddlewareFactory {
|
||||
compression(): RequestHandler;
|
||||
cors(): RequestHandler;
|
||||
static create(options: MiddlewareFactoryOptions): MiddlewareFactory;
|
||||
error(options?: MiddlewareFactoryErrorOptions): ErrorRequestHandler;
|
||||
helmet(): RequestHandler;
|
||||
logging(): RequestHandler;
|
||||
notFound(): RequestHandler;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface MiddlewareFactoryErrorOptions {
|
||||
logAllErrors?: boolean;
|
||||
showStackTraces?: boolean;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface MiddlewareFactoryOptions {
|
||||
// (undocumented)
|
||||
config: ConfigService;
|
||||
// (undocumented)
|
||||
logger: LoggerService;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const permissionsFactory: (
|
||||
options?: undefined,
|
||||
) => ServiceFactory<PermissionsService>;
|
||||
|
||||
// @public
|
||||
export function readCorsOptions(config?: Config): CorsOptions;
|
||||
|
||||
// @public
|
||||
export function readHelmetOptions(config?: Config): HelmetOptions;
|
||||
|
||||
// @public
|
||||
export function readHttpServerOptions(config?: Config): HttpServerOptions;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface RootHttpRouterConfigureOptions {
|
||||
// (undocumented)
|
||||
app: Express_2;
|
||||
// (undocumented)
|
||||
config: ConfigService;
|
||||
// (undocumented)
|
||||
lifecycle: LifecycleService;
|
||||
// (undocumented)
|
||||
logger: LoggerService;
|
||||
// (undocumented)
|
||||
middleware: MiddlewareFactory;
|
||||
// (undocumented)
|
||||
routes: RequestHandler;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const rootHttpRouterFactory: (
|
||||
options?: RootHttpRouterFactoryOptions | undefined,
|
||||
@@ -97,7 +199,7 @@ export const rootHttpRouterFactory: (
|
||||
// @public (undocumented)
|
||||
export type RootHttpRouterFactoryOptions = {
|
||||
indexPath?: string | false;
|
||||
middleware?: Handler[];
|
||||
configure?(options: RootHttpRouterConfigureOptions): void;
|
||||
};
|
||||
|
||||
// @public
|
||||
|
||||
@@ -36,15 +36,34 @@
|
||||
"@backstage/backend-common": "workspace:^",
|
||||
"@backstage/backend-plugin-api": "workspace:^",
|
||||
"@backstage/backend-tasks": "workspace:^",
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/plugin-permission-node": "workspace:^",
|
||||
"@types/cors": "^2.8.6",
|
||||
"@types/express": "^4.17.6",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^4.1.0",
|
||||
"fs-extra": "10.1.0",
|
||||
"helmet": "^6.0.0",
|
||||
"minimatch": "^5.0.0",
|
||||
"morgan": "^1.10.0",
|
||||
"node-forge": "^1.3.1",
|
||||
"selfsigned": "^2.0.0",
|
||||
"stoppable": "^1.1.0",
|
||||
"winston": "^3.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "workspace:^"
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@types/compression": "^1.7.0",
|
||||
"@types/fs-extra": "^9.0.3",
|
||||
"@types/http-errors": "^2.0.0",
|
||||
"@types/morgan": "^1.9.0",
|
||||
"@types/node-forge": "^1.3.0",
|
||||
"@types/stoppable": "^1.1.0",
|
||||
"http-errors": "^2.0.0",
|
||||
"supertest": "^6.1.3"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* 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 {
|
||||
AuthenticationError,
|
||||
ConflictError,
|
||||
InputError,
|
||||
NotAllowedError,
|
||||
NotFoundError,
|
||||
NotModifiedError,
|
||||
} from '@backstage/errors';
|
||||
import express from 'express';
|
||||
import createError from 'http-errors';
|
||||
import request from 'supertest';
|
||||
import { MiddlewareFactory } from './MiddlewareFactory';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
|
||||
describe('MiddlewareFactory', () => {
|
||||
describe('middleware.error', () => {
|
||||
const childLogger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
child: jest.fn(),
|
||||
};
|
||||
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
child: () => childLogger,
|
||||
};
|
||||
|
||||
const middleware = MiddlewareFactory.create({
|
||||
logger,
|
||||
config: new ConfigReader({}),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('gives default code and message', async () => {
|
||||
const app = express();
|
||||
app.use('/breaks', () => {
|
||||
throw new Error('some message');
|
||||
});
|
||||
app.use(middleware.error());
|
||||
|
||||
const response = await request(app).get('/breaks');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({
|
||||
error: expect.objectContaining({
|
||||
name: 'Error',
|
||||
message: 'some message',
|
||||
}),
|
||||
request: { method: 'GET', url: '/breaks' },
|
||||
response: { statusCode: 500 },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not try to send the response again if its already been sent', async () => {
|
||||
const app = express();
|
||||
const mockSend = jest.fn();
|
||||
|
||||
app.use('/works_with_async_fail', (_, res) => {
|
||||
res.status(200).send('hello');
|
||||
|
||||
// mutate the response object to test the middleware.
|
||||
// it's hard to catch errors inside middleware from the outside.
|
||||
res.send = mockSend;
|
||||
throw new Error('some message');
|
||||
});
|
||||
|
||||
app.use(middleware.error());
|
||||
const response = await request(app).get('/works_with_async_fail');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toBe('hello');
|
||||
|
||||
expect(mockSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('takes code from http-errors library errors', async () => {
|
||||
const app = express();
|
||||
app.use('/breaks', () => {
|
||||
throw createError(432, 'Some Message');
|
||||
});
|
||||
app.use(middleware.error());
|
||||
|
||||
const response = await request(app).get('/breaks');
|
||||
|
||||
expect(response.status).toBe(432);
|
||||
expect(response.body).toEqual({
|
||||
error: {
|
||||
expose: true,
|
||||
name: 'BadRequestError',
|
||||
message: 'Some Message',
|
||||
status: 432,
|
||||
statusCode: 432,
|
||||
},
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '/breaks',
|
||||
},
|
||||
response: { statusCode: 432 },
|
||||
});
|
||||
});
|
||||
|
||||
it('handles well-known error classes', async () => {
|
||||
const app = express();
|
||||
app.use('/NotModifiedError', () => {
|
||||
throw new NotModifiedError();
|
||||
});
|
||||
app.use('/InputError', () => {
|
||||
throw new InputError();
|
||||
});
|
||||
app.use('/AuthenticationError', () => {
|
||||
throw new AuthenticationError();
|
||||
});
|
||||
app.use('/NotAllowedError', () => {
|
||||
throw new NotAllowedError();
|
||||
});
|
||||
app.use('/NotFoundError', () => {
|
||||
throw new NotFoundError();
|
||||
});
|
||||
app.use('/ConflictError', () => {
|
||||
throw new ConflictError();
|
||||
});
|
||||
app.use(middleware.error());
|
||||
|
||||
const r = request(app);
|
||||
expect((await r.get('/NotModifiedError')).status).toBe(304);
|
||||
expect((await r.get('/InputError')).status).toBe(400);
|
||||
expect((await r.get('/InputError')).body.error.name).toBe('InputError');
|
||||
expect((await r.get('/AuthenticationError')).status).toBe(401);
|
||||
expect((await r.get('/AuthenticationError')).body.error.name).toBe(
|
||||
'AuthenticationError',
|
||||
);
|
||||
expect((await r.get('/NotAllowedError')).status).toBe(403);
|
||||
expect((await r.get('/NotAllowedError')).body.error.name).toBe(
|
||||
'NotAllowedError',
|
||||
);
|
||||
expect((await r.get('/NotFoundError')).status).toBe(404);
|
||||
expect((await r.get('/NotFoundError')).body.error.name).toBe(
|
||||
'NotFoundError',
|
||||
);
|
||||
expect((await r.get('/ConflictError')).status).toBe(409);
|
||||
expect((await r.get('/ConflictError')).body.error.name).toBe(
|
||||
'ConflictError',
|
||||
);
|
||||
});
|
||||
|
||||
it('logs all 500 errors', async () => {
|
||||
const app = express();
|
||||
const thrownError = new Error('some error');
|
||||
|
||||
app.use('/breaks', () => {
|
||||
throw thrownError;
|
||||
});
|
||||
app.use(middleware.error());
|
||||
|
||||
await request(app).get('/breaks');
|
||||
|
||||
expect(childLogger.error).toHaveBeenCalledWith(
|
||||
'Request failed with status 500',
|
||||
thrownError,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not log 400 errors', async () => {
|
||||
const app = express();
|
||||
|
||||
app.use('/NotFound', () => {
|
||||
throw new NotFoundError();
|
||||
});
|
||||
app.use(middleware.error());
|
||||
|
||||
await request(app).get('/NotFound');
|
||||
|
||||
expect(childLogger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('log 400 errors when logAllErrors is true', async () => {
|
||||
const app = express();
|
||||
|
||||
app.use('/NotFound', () => {
|
||||
throw new NotFoundError();
|
||||
});
|
||||
app.use(middleware.error({ logAllErrors: true }));
|
||||
|
||||
await request(app).get('/NotFound');
|
||||
|
||||
expect(childLogger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ConfigService, LoggerService } from '@backstage/backend-plugin-api';
|
||||
import {
|
||||
Request,
|
||||
Response,
|
||||
ErrorRequestHandler,
|
||||
NextFunction,
|
||||
RequestHandler,
|
||||
} from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import morgan from 'morgan';
|
||||
import compression from 'compression';
|
||||
import { readHelmetOptions } from './readHelmetOptions';
|
||||
import { readCorsOptions } from './readCorsOptions';
|
||||
import {
|
||||
AuthenticationError,
|
||||
ConflictError,
|
||||
ErrorResponseBody,
|
||||
InputError,
|
||||
NotAllowedError,
|
||||
NotFoundError,
|
||||
NotModifiedError,
|
||||
serializeError,
|
||||
} from '@backstage/errors';
|
||||
|
||||
/**
|
||||
* Options used to create a {@link MiddlewareFactory}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface MiddlewareFactoryOptions {
|
||||
config: ConfigService;
|
||||
logger: LoggerService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options passed to the {@link MiddlewareFactory.error} middleware.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface MiddlewareFactoryErrorOptions {
|
||||
/**
|
||||
* Whether error response bodies should show error stack traces or not.
|
||||
*
|
||||
* If not specified, by default shows stack traces only in development mode.
|
||||
*/
|
||||
showStackTraces?: boolean;
|
||||
|
||||
/**
|
||||
* Whether any 4xx errors should be logged or not.
|
||||
*
|
||||
* If not specified, default to only logging 5xx errors.
|
||||
*/
|
||||
logAllErrors?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A utility to configure common middleware.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class MiddlewareFactory {
|
||||
#config: ConfigService;
|
||||
#logger: LoggerService;
|
||||
|
||||
/**
|
||||
* Creates a new {@link MiddlewareFactory}.
|
||||
*/
|
||||
static create(options: MiddlewareFactoryOptions) {
|
||||
return new MiddlewareFactory(options);
|
||||
}
|
||||
|
||||
private constructor(options: MiddlewareFactoryOptions) {
|
||||
this.#config = options.config;
|
||||
this.#logger = options.logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a middleware that unconditionally produces a 404 error response.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Typically you want to place this middleware at the end of the chain, such
|
||||
* that it's the last one attempted after no other routes matched.
|
||||
*
|
||||
* @returns An Express request handler
|
||||
*/
|
||||
notFound(): RequestHandler {
|
||||
return (_req: Request, res: Response) => {
|
||||
res.status(404).end();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the compression middleware.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* The middleware will attempt to compress response bodies for all requests
|
||||
* that traverse through the middleware.
|
||||
*/
|
||||
compression(): RequestHandler {
|
||||
return compression();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a request logging middleware.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Typically you want to place this middleware at the start of the chain, such
|
||||
* that it always logs requests whether they are "caught" by handlers farther
|
||||
* down or not.
|
||||
*
|
||||
* @returns An Express request handler
|
||||
*/
|
||||
logging(): RequestHandler {
|
||||
const logger = this.#logger.child({
|
||||
type: 'incomingRequest',
|
||||
});
|
||||
|
||||
return morgan('combined', {
|
||||
stream: {
|
||||
write(message: string) {
|
||||
logger.info(message.trimEnd());
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a middleware that implements the helmet library.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This middleware applies security policies to incoming requests and outgoing
|
||||
* responses. It is configured using config keys such as `backend.csp`.
|
||||
*
|
||||
* @see {@link https://helmetjs.github.io/}
|
||||
*
|
||||
* @returns An Express request handler
|
||||
*/
|
||||
helmet(): RequestHandler {
|
||||
return helmet(readHelmetOptions(this.#config.getOptionalConfig('backend')));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a middleware that implements the cors library.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This middleware handles CORS. It is configured using the config key
|
||||
* `backend.cors`.
|
||||
*
|
||||
* @see {@link https://github.com/expressjs/cors}
|
||||
*
|
||||
* @returns An Express request handler
|
||||
*/
|
||||
cors(): RequestHandler {
|
||||
return cors(readCorsOptions(this.#config.getOptionalConfig('backend')));
|
||||
}
|
||||
|
||||
/**
|
||||
* Express middleware to handle errors during request processing.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This is commonly the very last middleware in the chain.
|
||||
*
|
||||
* Its primary purpose is not to do translation of business logic exceptions,
|
||||
* but rather to be a global catch-all for uncaught "fatal" errors that are
|
||||
* expected to result in a 500 error. However, it also does handle some common
|
||||
* error types (such as http-error exceptions, and the well-known error types
|
||||
* in the `@backstage/errors` package) and returns the enclosed status code
|
||||
* accordingly.
|
||||
*
|
||||
* It will also produce a response body with a serialized form of the error,
|
||||
* unless a previous handler already did send a body. See
|
||||
* {@link @backstage/errors#ErrorResponseBody} for the response shape used.
|
||||
*
|
||||
* @returns An Express error request handler
|
||||
*/
|
||||
error(options: MiddlewareFactoryErrorOptions = {}): ErrorRequestHandler {
|
||||
const showStackTraces =
|
||||
options.showStackTraces ?? process.env.NODE_ENV === 'development';
|
||||
|
||||
const logger = this.#logger.child({
|
||||
type: 'errorHandler',
|
||||
});
|
||||
|
||||
return (error: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
const statusCode = getStatusCode(error);
|
||||
if (options.logAllErrors || statusCode >= 500) {
|
||||
logger.error(`Request failed with status ${statusCode}`, error);
|
||||
}
|
||||
|
||||
if (res.headersSent) {
|
||||
// If the headers have already been sent, do not send the response again
|
||||
// as this will throw an error in the backend.
|
||||
next(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const body: ErrorResponseBody = {
|
||||
error: serializeError(error, { includeStack: showStackTraces }),
|
||||
request: { method: req.method, url: req.url },
|
||||
response: { statusCode },
|
||||
};
|
||||
|
||||
res.status(statusCode).json(body);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusCode(error: Error): number {
|
||||
// Look for common http library status codes
|
||||
const knownStatusCodeFields = ['statusCode', 'status'];
|
||||
for (const field of knownStatusCodeFields) {
|
||||
const statusCode = (error as any)[field];
|
||||
if (
|
||||
typeof statusCode === 'number' &&
|
||||
(statusCode | 0) === statusCode && // is whole integer
|
||||
statusCode >= 100 &&
|
||||
statusCode <= 599
|
||||
) {
|
||||
return statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle well-known error types
|
||||
switch (error.name) {
|
||||
case NotModifiedError.name:
|
||||
return 304;
|
||||
case InputError.name:
|
||||
return 400;
|
||||
case AuthenticationError.name:
|
||||
return 401;
|
||||
case NotAllowedError.name:
|
||||
return 403;
|
||||
case NotFoundError.name:
|
||||
return 404;
|
||||
case ConflictError.name:
|
||||
return 409;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Fall back to internal server error
|
||||
return 500;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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 { ConfigReader } from '@backstage/config';
|
||||
import { readHttpServerOptions } from './config';
|
||||
|
||||
describe('readHttpServerOptions', () => {
|
||||
it('should return defaults', () => {
|
||||
expect(readHttpServerOptions()).toEqual({
|
||||
listen: { host: '', port: 7007 },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{}, { listen: { host: '', port: 7007 } }],
|
||||
[{ listen: ':80' }, { listen: { host: '', port: 80 } }],
|
||||
[{ listen: '80' }, { listen: { host: '', port: 80 } }],
|
||||
[{ listen: '1.2.3.4:80' }, { listen: { host: '1.2.3.4', port: 80 } }],
|
||||
[{ listen: { host: '' } }, { listen: { host: '', port: 7007 } }],
|
||||
[
|
||||
{ listen: { host: '0.0.0.0' } },
|
||||
{ listen: { host: '0.0.0.0', port: 7007 } },
|
||||
],
|
||||
[
|
||||
{ listen: { host: '0.0.0.0', port: '80' } },
|
||||
{ listen: { host: '0.0.0.0', port: 80 } },
|
||||
],
|
||||
[{ listen: { port: '80' } }, { listen: { host: '', port: 80 } }],
|
||||
[{ listen: { port: 80 } }, { listen: { host: '', port: 80 } }],
|
||||
[{ listen: { port: 80 } }, { listen: { host: '', port: 80 } }],
|
||||
[
|
||||
{ baseUrl: 'http://example.com:8080', https: true },
|
||||
{
|
||||
listen: { host: '', port: 7007 },
|
||||
https: { certificate: { type: 'generated', hostname: 'example.com' } },
|
||||
},
|
||||
],
|
||||
[
|
||||
{ https: { certificate: { cert: 'my-cert', key: 'my-key' } } },
|
||||
{
|
||||
listen: { host: '', port: 7007 },
|
||||
https: {
|
||||
certificate: { type: 'plain', cert: 'my-cert', key: 'my-key' },
|
||||
},
|
||||
},
|
||||
],
|
||||
])('should read http server options %#', (input, output) => {
|
||||
expect(readHttpServerOptions(new ConfigReader(input))).toEqual(output);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
{ listen: { port: 'not-a-number' } },
|
||||
"Unable to convert config value for key 'listen.port' in 'mock-config' to a number",
|
||||
],
|
||||
[
|
||||
{ listen: { port: {} } },
|
||||
"Invalid type in config for key 'listen.port' in 'mock-config', got object, wanted number",
|
||||
],
|
||||
[
|
||||
{ listen: { host: false } },
|
||||
"Invalid type in config for key 'listen.host' in 'mock-config', got boolean, wanted string",
|
||||
],
|
||||
[{ https: {} }, "Missing required config value at 'https.certificate.cert"],
|
||||
[
|
||||
{ https: { certificate: { cert: 'x' } } },
|
||||
"Missing required config value at 'https.certificate.key",
|
||||
],
|
||||
])('should throw on bad options %#', (input, message) => {
|
||||
expect(() => readHttpServerOptions(new ConfigReader(input))).toThrow(
|
||||
message,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import { HttpServerOptions } from './types';
|
||||
|
||||
const DEFAULT_PORT = 7007;
|
||||
const DEFAULT_HOST = '';
|
||||
|
||||
/**
|
||||
* Reads {@link HttpServerOptions} from a {@link @backstage/config#Config} object.
|
||||
*
|
||||
* @public
|
||||
* @remarks
|
||||
*
|
||||
* The provided configuration object should contain the `listen` and
|
||||
* additional keys directly.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const opts = readHttpServerOptions(config.getConfig('backend'));
|
||||
* ```
|
||||
*/
|
||||
export function readHttpServerOptions(config?: Config): HttpServerOptions {
|
||||
return {
|
||||
listen: readHttpListenOptions(config),
|
||||
https: readHttpsOptions(config),
|
||||
};
|
||||
}
|
||||
|
||||
function readHttpListenOptions(config?: Config): HttpServerOptions['listen'] {
|
||||
const listen = config?.getOptional('listen');
|
||||
if (typeof listen === 'string') {
|
||||
const parts = String(listen).split(':');
|
||||
const port = parseInt(parts[parts.length - 1], 10);
|
||||
if (!isNaN(port)) {
|
||||
if (parts.length === 1) {
|
||||
return { port, host: DEFAULT_HOST };
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return { host: parts[0], port };
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Unable to parse listen address ${listen}, expected <port> or <host>:<port>`,
|
||||
);
|
||||
}
|
||||
|
||||
// Workaround to allow empty string
|
||||
const host = config?.getOptional('listen.host') ?? DEFAULT_HOST;
|
||||
if (typeof host !== 'string') {
|
||||
config?.getOptionalString('listen.host'); // will throw
|
||||
throw new Error('unreachable');
|
||||
}
|
||||
|
||||
return {
|
||||
port: config?.getOptionalNumber('listen.port') ?? DEFAULT_PORT,
|
||||
host,
|
||||
};
|
||||
}
|
||||
|
||||
function readHttpsOptions(config?: Config): HttpServerOptions['https'] {
|
||||
const https = config?.getOptional('https');
|
||||
if (https === true) {
|
||||
const baseUrl = config!.getString('baseUrl');
|
||||
let hostname;
|
||||
try {
|
||||
hostname = new URL(baseUrl).hostname;
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid baseUrl "${baseUrl}"`);
|
||||
}
|
||||
|
||||
return { certificate: { type: 'generated', hostname } };
|
||||
}
|
||||
|
||||
const cc = config?.getOptionalConfig('https');
|
||||
if (!cc) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
certificate: {
|
||||
type: 'plain',
|
||||
cert: cc.getString('certificate.cert'),
|
||||
key: cc.getString('certificate.key'),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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 * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import stoppableServer from 'stoppable';
|
||||
import { RequestListener } from 'http';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { HttpServerOptions, ExtendedHttpServer } from './types';
|
||||
import { getGeneratedCertificate } from './getGeneratedCertificate';
|
||||
|
||||
/**
|
||||
* Creates a Node.js HTTP or HTTPS server instance.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export async function createHttpServer(
|
||||
listener: RequestListener,
|
||||
options: HttpServerOptions,
|
||||
deps: { logger: LoggerService },
|
||||
): Promise<ExtendedHttpServer> {
|
||||
const server = await createServer(listener, options, deps);
|
||||
|
||||
const stopper = stoppableServer(server, 0);
|
||||
// The stopper here is actually the server itself, so if we try
|
||||
// to call stopper.stop() down in the stop implementation, we'll
|
||||
// be calling ourselves.
|
||||
const stopServer = stopper.stop.bind(stopper);
|
||||
|
||||
return Object.assign(server, {
|
||||
start() {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const handleStartupError = (error: Error) => {
|
||||
server.close();
|
||||
reject(error);
|
||||
};
|
||||
|
||||
server.on('error', handleStartupError);
|
||||
|
||||
const { host, port } = options.listen;
|
||||
server.listen(port, host, () => {
|
||||
server.off('error', handleStartupError);
|
||||
deps.logger.info(`Listening on ${host}:${port}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
stop() {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
stopServer((error?: Error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
port() {
|
||||
const address = server.address();
|
||||
if (typeof address === 'string' || address === null) {
|
||||
throw new Error(`Unexpected server address '${address}'`);
|
||||
}
|
||||
return address.port;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createServer(
|
||||
listener: RequestListener,
|
||||
options: HttpServerOptions,
|
||||
deps: { logger: LoggerService },
|
||||
): Promise<http.Server> {
|
||||
if (options.https) {
|
||||
const { certificate } = options.https;
|
||||
if (certificate.type === 'generated') {
|
||||
const credentials = await getGeneratedCertificate(
|
||||
certificate.hostname,
|
||||
deps.logger,
|
||||
);
|
||||
return https.createServer(credentials, listener);
|
||||
}
|
||||
return https.createServer(certificate, listener);
|
||||
}
|
||||
|
||||
return http.createServer(listener);
|
||||
}
|
||||
+19
-83
@@ -16,86 +16,16 @@
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { resolve as resolvePath, dirname } from 'path';
|
||||
import express from 'express';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { HttpsSettings } from './config';
|
||||
import forge from 'node-forge';
|
||||
|
||||
const FIVE_DAYS_IN_MS = 5 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const IP_HOSTNAME_REGEX = /:|^\d+\.\d+\.\d+\.\d+$/;
|
||||
|
||||
/**
|
||||
* Creates a Http server instance based on an Express application.
|
||||
*
|
||||
* @param app - The Express application object
|
||||
* @param logger - Optional Winston logger object
|
||||
* @returns A Http server instance
|
||||
*
|
||||
*/
|
||||
export function createHttpServer(
|
||||
app: express.Express,
|
||||
logger?: LoggerService,
|
||||
): http.Server {
|
||||
logger?.info('Initializing http server');
|
||||
|
||||
return http.createServer(app);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Https server instance based on an Express application.
|
||||
*
|
||||
* @param app - The Express application object
|
||||
* @param httpsSettings - HttpsSettings for self-signed certificate generation
|
||||
* @param logger - Optional Winston logger object
|
||||
* @returns A Https server instance
|
||||
*
|
||||
*/
|
||||
export async function createHttpsServer(
|
||||
app: express.Express,
|
||||
httpsSettings: HttpsSettings,
|
||||
logger?: LoggerService,
|
||||
): Promise<http.Server> {
|
||||
logger?.info('Initializing https server');
|
||||
|
||||
let credentials: { key: string | Buffer; cert: string | Buffer };
|
||||
|
||||
if ('hostname' in httpsSettings?.certificate) {
|
||||
credentials = await getGeneratedCertificate(
|
||||
httpsSettings.certificate.hostname,
|
||||
logger,
|
||||
);
|
||||
} else {
|
||||
logger?.info('Loading certificate from config');
|
||||
|
||||
credentials = {
|
||||
key: httpsSettings?.certificate?.key,
|
||||
cert: httpsSettings?.certificate?.cert,
|
||||
};
|
||||
}
|
||||
|
||||
if (!credentials.key || !credentials.cert) {
|
||||
throw new Error('Invalid HTTPS credentials');
|
||||
}
|
||||
|
||||
return https.createServer(credentials, app) as http.Server;
|
||||
}
|
||||
|
||||
function getCertificateExpiration(cert: string, logger?: LoggerService) {
|
||||
try {
|
||||
const crt = forge.pki.certificateFromPem(cert);
|
||||
return crt.validity.notAfter.getTime() - Date.now();
|
||||
} catch (error) {
|
||||
logger?.warn(`Unable to parse self-signed certificate. ${error}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function getGeneratedCertificate(
|
||||
export async function getGeneratedCertificate(
|
||||
hostname: string,
|
||||
logger?: LoggerService,
|
||||
logger: LoggerService,
|
||||
) {
|
||||
const hasModules = await fs.pathExists('node_modules');
|
||||
let certPath;
|
||||
@@ -109,24 +39,30 @@ async function getGeneratedCertificate(
|
||||
}
|
||||
|
||||
if (await fs.pathExists(certPath)) {
|
||||
const cert = await fs.readFile(certPath);
|
||||
const remainingMs = getCertificateExpiration(cert.toString(), logger);
|
||||
if (remainingMs > FIVE_DAYS_IN_MS) {
|
||||
logger?.info('Using existing self-signed certificate');
|
||||
return {
|
||||
key: cert,
|
||||
cert,
|
||||
};
|
||||
try {
|
||||
const cert = await fs.readFile(certPath);
|
||||
|
||||
const crt = forge.pki.certificateFromPem(cert.toString());
|
||||
const remainingMs = crt.validity.notAfter.getTime() - Date.now();
|
||||
if (remainingMs > FIVE_DAYS_IN_MS) {
|
||||
logger.info('Using existing self-signed certificate');
|
||||
return {
|
||||
key: cert,
|
||||
cert,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Unable to use existing self-signed certificate, ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger?.info('Generating new self-signed certificate');
|
||||
const newCert = await createCertificate(hostname);
|
||||
logger.info('Generating new self-signed certificate');
|
||||
const newCert = await generateCertificate(hostname);
|
||||
await fs.writeFile(certPath, newCert.cert + newCert.key, 'utf8');
|
||||
return newCert;
|
||||
}
|
||||
|
||||
async function createCertificate(hostname: string) {
|
||||
async function generateCertificate(hostname: string) {
|
||||
const attributes = [
|
||||
{
|
||||
name: 'commonName',
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { readHttpServerOptions } from './config';
|
||||
export { createHttpServer } from './createHttpServer';
|
||||
export { MiddlewareFactory } from './MiddlewareFactory';
|
||||
export type {
|
||||
MiddlewareFactoryErrorOptions,
|
||||
MiddlewareFactoryOptions,
|
||||
} from './MiddlewareFactory';
|
||||
export { readCorsOptions } from './readCorsOptions';
|
||||
export { readHelmetOptions } from './readHelmetOptions';
|
||||
export type {
|
||||
ExtendedHttpServer,
|
||||
HttpServerCertificateOptions,
|
||||
HttpServerOptions,
|
||||
} from './types';
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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 { ConfigReader } from '@backstage/config';
|
||||
import { readCorsOptions } from './readCorsOptions';
|
||||
|
||||
describe('readCorsOptions', () => {
|
||||
it('should be disabled by default', () => {
|
||||
expect(readCorsOptions()).toEqual({
|
||||
origin: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads single string', () => {
|
||||
const mockCallback = jest.fn();
|
||||
const config = new ConfigReader({ cors: { origin: 'https://*.value*' } });
|
||||
const cors = readCorsOptions(config);
|
||||
expect(cors).toEqual(
|
||||
expect.objectContaining({
|
||||
origin: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
const origin = cors?.origin as Function;
|
||||
origin('https://a.value', mockCallback); // valid origin
|
||||
origin('http://a.value', mockCallback); // invalid origin
|
||||
origin(undefined, mockCallback); // when not origin needs to reject the call
|
||||
|
||||
expect(mockCallback.mock.calls[0][0]).toBe(null);
|
||||
expect(mockCallback.mock.calls[1][0]).toBe(null);
|
||||
|
||||
expect(mockCallback.mock.calls[0][1]).toBe(true);
|
||||
expect(mockCallback.mock.calls[1][1]).toBe(false);
|
||||
expect(mockCallback.mock.calls[2][1]).toBe(false);
|
||||
});
|
||||
|
||||
it('reads string array', () => {
|
||||
const mockCallback = jest.fn();
|
||||
const config = new ConfigReader({
|
||||
cors: {
|
||||
origin: ['http?(s)://*.value?(-+([0-9])).com', 'http://*.value'],
|
||||
},
|
||||
});
|
||||
const cors = readCorsOptions(config);
|
||||
expect(cors).toEqual(
|
||||
expect.objectContaining({
|
||||
origin: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
const origin = cors?.origin as Function;
|
||||
origin('https://a.b.c.value-9.com', mockCallback);
|
||||
origin('http://a.value-999.com', mockCallback);
|
||||
origin('http://a.value', mockCallback);
|
||||
origin('http://a.valuex', mockCallback);
|
||||
|
||||
expect(mockCallback.mock.calls[0][0]).toBe(null);
|
||||
expect(mockCallback.mock.calls[1][0]).toBe(null);
|
||||
expect(mockCallback.mock.calls[2][0]).toBe(null);
|
||||
expect(mockCallback.mock.calls[3][0]).toBe(null);
|
||||
|
||||
expect(mockCallback.mock.calls[0][1]).toBe(true);
|
||||
expect(mockCallback.mock.calls[1][1]).toBe(true);
|
||||
expect(mockCallback.mock.calls[2][1]).toBe(true);
|
||||
expect(mockCallback.mock.calls[3][1]).toBe(false);
|
||||
});
|
||||
|
||||
it('reads undefined origin', () => {
|
||||
const config = new ConfigReader({
|
||||
cors: {},
|
||||
});
|
||||
const cors = readCorsOptions(config);
|
||||
expect(cors).toEqual(expect.objectContaining({}));
|
||||
expect(cors?.origin).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import { CorsOptions } from 'cors';
|
||||
import { Minimatch } from 'minimatch';
|
||||
|
||||
/**
|
||||
* Attempts to read a CORS options object from the backend configuration object.
|
||||
*
|
||||
* @public
|
||||
* @param config - The backend configuration object.
|
||||
* @returns A CORS options object, or undefined if no cors configuration is present.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const corsOptions = readCorsOptions(config.getConfig('backend'));
|
||||
* ```
|
||||
*/
|
||||
export function readCorsOptions(config?: Config): CorsOptions {
|
||||
const cc = config?.getOptionalConfig('cors');
|
||||
if (!cc) {
|
||||
return { origin: false }; // Disable CORS
|
||||
}
|
||||
|
||||
return {
|
||||
origin: createCorsOriginMatcher(readStringArray(cc, 'origin')),
|
||||
methods: readStringArray(cc, 'methods'),
|
||||
allowedHeaders: readStringArray(cc, 'allowedHeaders'),
|
||||
exposedHeaders: readStringArray(cc, 'exposedHeaders'),
|
||||
credentials: cc.getOptionalBoolean('credentials'),
|
||||
maxAge: cc.getOptionalNumber('maxAge'),
|
||||
preflightContinue: cc.getOptionalBoolean('preflightContinue'),
|
||||
optionsSuccessStatus: cc.getOptionalNumber('optionsSuccessStatus'),
|
||||
};
|
||||
}
|
||||
|
||||
function readStringArray(config: Config, key: string): string[] | undefined {
|
||||
const value = config.getOptional(key);
|
||||
if (typeof value === 'string') {
|
||||
return [value];
|
||||
} else if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
return config.getStringArray(key);
|
||||
}
|
||||
|
||||
function createCorsOriginMatcher(allowedOriginPatterns: string[] | undefined) {
|
||||
if (!allowedOriginPatterns) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const allowedOriginMatchers = allowedOriginPatterns.map(
|
||||
pattern => new Minimatch(pattern, { nocase: true, noglobstar: true }),
|
||||
);
|
||||
|
||||
return (
|
||||
origin: string | undefined,
|
||||
callback: (
|
||||
err: Error | null,
|
||||
origin: boolean | string | RegExp | (boolean | string | RegExp)[],
|
||||
) => void,
|
||||
) => {
|
||||
return callback(
|
||||
null,
|
||||
allowedOriginMatchers.some(pattern => pattern.match(origin ?? '')),
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* 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 { ConfigReader } from '@backstage/config';
|
||||
import { readHelmetOptions } from './readHelmetOptions';
|
||||
|
||||
describe('readHelmetOptions', () => {
|
||||
it('should return defaults', () => {
|
||||
expect(readHelmetOptions()).toEqual({
|
||||
contentSecurityPolicy: {
|
||||
useDefaults: false,
|
||||
directives: {
|
||||
'default-src': ["'self'"],
|
||||
'base-uri': ["'self'"],
|
||||
'font-src': ["'self'", 'https:', 'data:'],
|
||||
'frame-ancestors': ["'self'"],
|
||||
'img-src': ["'self'", 'data:'],
|
||||
'object-src': ["'none'"],
|
||||
'script-src': ["'self'", "'unsafe-eval'"],
|
||||
'style-src': ["'self'", 'https:', "'unsafe-inline'"],
|
||||
'script-src-attr': ["'none'"],
|
||||
'upgrade-insecure-requests': [],
|
||||
},
|
||||
},
|
||||
crossOriginEmbedderPolicy: false,
|
||||
crossOriginOpenerPolicy: false,
|
||||
crossOriginResourcePolicy: false,
|
||||
originAgentCluster: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should add additional directives', () => {
|
||||
const config = new ConfigReader({
|
||||
csp: {
|
||||
key: ['value'],
|
||||
'img-src': false,
|
||||
'script-src-attr': ['custom'],
|
||||
},
|
||||
});
|
||||
expect(readHelmetOptions(config)).toEqual({
|
||||
contentSecurityPolicy: {
|
||||
useDefaults: false,
|
||||
directives: {
|
||||
'default-src': ["'self'"],
|
||||
'base-uri': ["'self'"],
|
||||
'font-src': ["'self'", 'https:', 'data:'],
|
||||
'frame-ancestors': ["'self'"],
|
||||
'object-src': ["'none'"],
|
||||
'script-src': ["'self'", "'unsafe-eval'"],
|
||||
'style-src': ["'self'", 'https:', "'unsafe-inline'"],
|
||||
'script-src-attr': ['custom'],
|
||||
'upgrade-insecure-requests': [],
|
||||
key: ['value'],
|
||||
},
|
||||
},
|
||||
crossOriginEmbedderPolicy: false,
|
||||
crossOriginOpenerPolicy: false,
|
||||
crossOriginResourcePolicy: false,
|
||||
originAgentCluster: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid value types', () => {
|
||||
const config = new ConfigReader({ csp: { key: [4] } });
|
||||
expect(() => readHelmetOptions(config)).toThrow(/wanted string-array/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* 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 { Config } from '@backstage/config';
|
||||
import helmet from 'helmet';
|
||||
import { HelmetOptions } from 'helmet';
|
||||
import { ContentSecurityPolicyOptions } from 'helmet/dist/types/middlewares/content-security-policy';
|
||||
|
||||
/**
|
||||
* Attempts to read Helmet options from the backend configuration object.
|
||||
*
|
||||
* @public
|
||||
* @param config - The backend configuration object.
|
||||
* @returns A Helmet options object, or undefined if no Helmet configuration is present.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const helmetOptions = readHelmetOptions(config.getConfig('backend'));
|
||||
* ```
|
||||
*/
|
||||
export function readHelmetOptions(config?: Config): HelmetOptions {
|
||||
const cspOptions = readCspDirectives(config);
|
||||
return {
|
||||
contentSecurityPolicy: {
|
||||
useDefaults: false,
|
||||
directives: applyCspDirectives(cspOptions),
|
||||
},
|
||||
// These are all disabled in order to maintain backwards compatibility
|
||||
// when bumping helmet v5. We can't enable these by default because
|
||||
// there is no way for users to configure them.
|
||||
// TODO(Rugvip): We should give control of this setup to consumers
|
||||
crossOriginEmbedderPolicy: false,
|
||||
crossOriginOpenerPolicy: false,
|
||||
crossOriginResourcePolicy: false,
|
||||
originAgentCluster: false,
|
||||
};
|
||||
}
|
||||
|
||||
type CspDirectives = Record<string, string[] | false> | undefined;
|
||||
|
||||
/**
|
||||
* Attempts to read a CSP directives from the backend configuration object.
|
||||
*
|
||||
* @example
|
||||
* ```yaml
|
||||
* backend:
|
||||
* csp:
|
||||
* connect-src: ["'self'", 'http:', 'https:']
|
||||
* upgrade-insecure-requests: false
|
||||
* ```
|
||||
*/
|
||||
function readCspDirectives(config?: Config): CspDirectives {
|
||||
const cc = config?.getOptionalConfig('csp');
|
||||
if (!cc) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result: Record<string, string[] | false> = {};
|
||||
for (const key of cc.keys()) {
|
||||
if (cc.get(key) === false) {
|
||||
result[key] = false;
|
||||
} else {
|
||||
result[key] = cc.getStringArray(key);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function applyCspDirectives(
|
||||
directives: CspDirectives,
|
||||
): ContentSecurityPolicyOptions['directives'] {
|
||||
const result: ContentSecurityPolicyOptions['directives'] =
|
||||
helmet.contentSecurityPolicy.getDefaultDirectives();
|
||||
|
||||
// TODO(Rugvip): We currently use non-precompiled AJV for validation in the frontend, which uses eval.
|
||||
// It should be replaced by any other solution that doesn't require unsafe-eval.
|
||||
result['script-src'] = ["'self'", "'unsafe-eval'"];
|
||||
|
||||
// TODO(Rugvip): This is removed so that we maintained backwards compatibility
|
||||
// when bumping to helmet v5, we could remove this as well as
|
||||
// skip setting `useDefaults: false` in the future.
|
||||
delete result['form-action'];
|
||||
|
||||
if (directives) {
|
||||
for (const [key, value] of Object.entries(directives)) {
|
||||
if (value === false) {
|
||||
delete result[key];
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as http from 'http';
|
||||
|
||||
/**
|
||||
* An HTTP server extended with utility methods.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface ExtendedHttpServer extends http.Server {
|
||||
start(): Promise<void>;
|
||||
|
||||
stop(): Promise<void>;
|
||||
|
||||
port(): number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for starting up an HTTP server.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type HttpServerOptions = {
|
||||
listen: {
|
||||
port: number;
|
||||
host: string;
|
||||
};
|
||||
https?: {
|
||||
certificate: HttpServerCertificateOptions;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for configuring HTTPS for an HTTP server.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type HttpServerCertificateOptions =
|
||||
| {
|
||||
type: 'plain';
|
||||
key: string;
|
||||
cert: string;
|
||||
}
|
||||
| {
|
||||
type: 'generated';
|
||||
hostname: string;
|
||||
};
|
||||
@@ -20,5 +20,6 @@
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export * from './http';
|
||||
export * from './wiring';
|
||||
export * from './services/implementations';
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ import {
|
||||
HttpRouterService,
|
||||
ServiceFactory,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { httpRouterFactory } from './httpRouterService';
|
||||
import { httpRouterFactory } from './httpRouterFactory';
|
||||
|
||||
describe('httpRouterFactory', () => {
|
||||
it('should register plugin paths', async () => {
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { httpRouterFactory } from './httpRouterFactory';
|
||||
export type { HttpRouterFactoryOptions } from './httpRouterFactory';
|
||||
@@ -14,6 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './httpRouter';
|
||||
export * from './rootHttpRouter';
|
||||
|
||||
export { cacheFactory } from './cacheService';
|
||||
export { configFactory } from './configService';
|
||||
export { databaseFactory } from './databaseService';
|
||||
@@ -24,9 +27,5 @@ export { permissionsFactory } from './permissionsService';
|
||||
export { schedulerFactory } from './schedulerService';
|
||||
export { tokenManagerFactory } from './tokenManagerService';
|
||||
export { urlReaderFactory } from './urlReaderService';
|
||||
export { httpRouterFactory } from './httpRouterService';
|
||||
export { rootHttpRouterFactory } from './rootHttpRouterService';
|
||||
export { lifecycleFactory } from './lifecycleService';
|
||||
export { rootLifecycleFactory } from './rootLifecycleService';
|
||||
export type { HttpRouterFactoryOptions } from './httpRouterService';
|
||||
export type { RootHttpRouterFactoryOptions } from './rootHttpRouterService';
|
||||
|
||||
@@ -28,7 +28,7 @@ export const loggerFactory = createServiceFactory({
|
||||
},
|
||||
async factory({ rootLogger }) {
|
||||
return async ({ plugin }) => {
|
||||
return rootLogger.child({ pluginId: plugin.getId() });
|
||||
return rootLogger.child({ plugin: plugin.getId() });
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2022 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 { RestrictedIndexedRouter } from './RestrictedIndexedRouter';
|
||||
|
||||
describe('RestrictedIndexedRouter', () => {
|
||||
it.each([
|
||||
[['/b'], '/a'],
|
||||
[['/a'], '/aa/b'],
|
||||
[['/aa'], '/a/b'],
|
||||
[['/a/b'], '/aa'],
|
||||
[['/b/a'], '/a'],
|
||||
[['/a'], '/aa'],
|
||||
])(`with existing paths %s, adds %s without conflict`, (existing, added) => {
|
||||
const router = new RestrictedIndexedRouter(false);
|
||||
for (const path of existing) {
|
||||
router.use(path, () => {});
|
||||
}
|
||||
expect(() => router.use(added, () => {})).not.toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[['/a'], '/a', '/a'],
|
||||
[['/a'], '/a/b', '/a'],
|
||||
[['/a/b'], '/a', '/a/b'],
|
||||
])(
|
||||
`find conflict when existing paths %s, adds %s`,
|
||||
(existing, added, conflict) => {
|
||||
const router = new RestrictedIndexedRouter(false);
|
||||
for (const path of existing) {
|
||||
router.use(path, () => {});
|
||||
}
|
||||
expect(() => router.use(added, () => {})).toThrow(
|
||||
`Path ${added} conflicts with the existing path ${conflict}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { RootHttpRouterService } from '@backstage/backend-plugin-api';
|
||||
import { Handler, Router } from 'express';
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.replace(/\/*$/, '/');
|
||||
}
|
||||
|
||||
export class RestrictedIndexedRouter implements RootHttpRouterService {
|
||||
#indexPath?: false | string;
|
||||
|
||||
#router = Router();
|
||||
#namedRoutes = Router();
|
||||
#indexRouter = Router();
|
||||
#existingPaths = new Array<string>();
|
||||
|
||||
constructor(indexPath?: false | string) {
|
||||
this.#indexPath = indexPath;
|
||||
this.#router.use(this.#namedRoutes);
|
||||
this.#router.use(this.#indexRouter);
|
||||
}
|
||||
|
||||
use(path: string, handler: Handler) {
|
||||
if (path.match(/^[/\s]*$/)) {
|
||||
throw new Error(`Root router path may not be empty`);
|
||||
}
|
||||
const conflictingPath = this.#findConflictingPath(path);
|
||||
if (conflictingPath) {
|
||||
throw new Error(
|
||||
`Path ${path} conflicts with the existing path ${conflictingPath}`,
|
||||
);
|
||||
}
|
||||
this.#existingPaths.push(path);
|
||||
this.#namedRoutes.use(path, handler);
|
||||
|
||||
if (this.#indexPath === path) {
|
||||
this.#indexRouter.use(handler);
|
||||
}
|
||||
}
|
||||
|
||||
handler(): Handler {
|
||||
return this.#router;
|
||||
}
|
||||
|
||||
#findConflictingPath(newPath: string): string | undefined {
|
||||
const normalizedNewPath = normalizePath(newPath);
|
||||
for (const path of this.#existingPaths) {
|
||||
const normalizedPath = normalizePath(path);
|
||||
if (normalizedPath.startsWith(normalizedNewPath)) {
|
||||
return path;
|
||||
}
|
||||
if (normalizedNewPath.startsWith(normalizedPath)) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { rootHttpRouterFactory } from './rootHttpRouterFactory';
|
||||
export type {
|
||||
RootHttpRouterFactoryOptions,
|
||||
RootHttpRouterConfigureOptions,
|
||||
} from './rootHttpRouterFactory';
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright 2022 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 {
|
||||
ConfigService,
|
||||
coreServices,
|
||||
createServiceFactory,
|
||||
LifecycleService,
|
||||
LoggerService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import express, { RequestHandler, Express } from 'express';
|
||||
import {
|
||||
createHttpServer,
|
||||
MiddlewareFactory,
|
||||
readHttpServerOptions,
|
||||
} from '../../../http';
|
||||
import { RestrictedIndexedRouter } from './RestrictedIndexedRouter';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface RootHttpRouterConfigureOptions {
|
||||
app: Express;
|
||||
middleware: MiddlewareFactory;
|
||||
routes: RequestHandler;
|
||||
config: ConfigService;
|
||||
logger: LoggerService;
|
||||
lifecycle: LifecycleService;
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type RootHttpRouterFactoryOptions = {
|
||||
/**
|
||||
* The path to forward all unmatched requests to. Defaults to '/api/app'
|
||||
*/
|
||||
indexPath?: string | false;
|
||||
|
||||
configure?(options: RootHttpRouterConfigureOptions): void;
|
||||
};
|
||||
|
||||
function defaultConfigure({
|
||||
app,
|
||||
routes,
|
||||
middleware,
|
||||
}: RootHttpRouterConfigureOptions) {
|
||||
app.use(middleware.helmet());
|
||||
app.use(middleware.cors());
|
||||
app.use(middleware.compression());
|
||||
app.use(middleware.logging());
|
||||
app.use(routes);
|
||||
app.use(middleware.notFound());
|
||||
app.use(middleware.error());
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export const rootHttpRouterFactory = createServiceFactory({
|
||||
service: coreServices.rootHttpRouter,
|
||||
deps: {
|
||||
config: coreServices.config,
|
||||
rootLogger: coreServices.rootLogger,
|
||||
lifecycle: coreServices.rootLifecycle,
|
||||
},
|
||||
async factory(
|
||||
{ config, rootLogger, lifecycle },
|
||||
{
|
||||
indexPath,
|
||||
configure = defaultConfigure,
|
||||
}: RootHttpRouterFactoryOptions = {},
|
||||
) {
|
||||
const router = new RestrictedIndexedRouter(indexPath ?? '/api/app');
|
||||
const logger = rootLogger.child({ service: 'rootHttpRouter' });
|
||||
|
||||
const app = express();
|
||||
|
||||
const middleware = MiddlewareFactory.create({ config, logger });
|
||||
|
||||
configure({
|
||||
app,
|
||||
routes: router.handler(),
|
||||
middleware,
|
||||
config,
|
||||
logger,
|
||||
lifecycle,
|
||||
});
|
||||
|
||||
const server = await createHttpServer(
|
||||
app,
|
||||
readHttpServerOptions(config.getOptionalConfig('backend')),
|
||||
{ logger },
|
||||
);
|
||||
|
||||
lifecycle.addShutdownHook({
|
||||
async fn() {
|
||||
await server.stop();
|
||||
},
|
||||
labels: { service: 'rootHttpRouter' },
|
||||
});
|
||||
|
||||
await server.start();
|
||||
|
||||
return router;
|
||||
},
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 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 { findConflictingPath } from './rootHttpRouterService';
|
||||
|
||||
describe('findConflictingPath', () => {
|
||||
it('finds conflicts when present', () => {
|
||||
expect(findConflictingPath(['/a'], '/a')).toBe('/a');
|
||||
expect(findConflictingPath(['/b'], '/a')).toBe(undefined);
|
||||
expect(findConflictingPath(['/a'], '/a/b')).toBe('/a');
|
||||
expect(findConflictingPath(['/a'], '/aa/b')).toBe(undefined);
|
||||
expect(findConflictingPath(['/aa'], '/a/b')).toBe(undefined);
|
||||
expect(findConflictingPath(['/a/b'], '/a')).toBe('/a/b');
|
||||
expect(findConflictingPath(['/a/b'], '/aa')).toBe(undefined);
|
||||
expect(findConflictingPath(['/b/a'], '/a')).toBe(undefined);
|
||||
expect(findConflictingPath(['/a'], '/aa')).toBe(undefined);
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 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 {
|
||||
createServiceFactory,
|
||||
coreServices,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import Router from 'express-promise-router';
|
||||
import { Handler } from 'express';
|
||||
import { createServiceBuilder } from '@backstage/backend-common';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type RootHttpRouterFactoryOptions = {
|
||||
/**
|
||||
* The path to forward all unmatched requests to. Defaults to '/api/app'
|
||||
*/
|
||||
indexPath?: string | false;
|
||||
|
||||
/**
|
||||
* Middlewares that are added before all other routes.
|
||||
*/
|
||||
middleware?: Handler[];
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export const rootHttpRouterFactory = createServiceFactory({
|
||||
service: coreServices.rootHttpRouter,
|
||||
deps: {
|
||||
config: coreServices.config,
|
||||
lifecycle: coreServices.rootLifecycle,
|
||||
},
|
||||
async factory({ config, lifecycle }, options?: RootHttpRouterFactoryOptions) {
|
||||
const indexPath = options?.indexPath ?? '/api/app';
|
||||
|
||||
const namedRouter = Router();
|
||||
const indexRouter = Router();
|
||||
|
||||
const service = createServiceBuilder(module).loadConfig(config);
|
||||
|
||||
for (const middleware of options?.middleware ?? []) {
|
||||
service.addRouter('', middleware);
|
||||
}
|
||||
|
||||
service.addRouter('', namedRouter).addRouter('', indexRouter);
|
||||
|
||||
const server = await service.start();
|
||||
// Stop method isn't part of the public API, let's fix that once we move the implementation here.
|
||||
const stoppableServer = server as typeof server & {
|
||||
stop: (cb: (error?: Error) => void) => void;
|
||||
};
|
||||
|
||||
lifecycle.addShutdownHook({
|
||||
async fn() {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stoppableServer.stop((error?: Error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
labels: { service: 'rootHttpRouter' },
|
||||
});
|
||||
|
||||
const existingPaths = new Array<string>();
|
||||
|
||||
return {
|
||||
use: (path: string, handler: Handler) => {
|
||||
if (path.match(/^[/\s]*$/)) {
|
||||
throw new Error(`Root router path may not be empty`);
|
||||
}
|
||||
const conflictingPath = findConflictingPath(existingPaths, path);
|
||||
if (conflictingPath) {
|
||||
throw new Error(
|
||||
`Path ${path} conflicts with the existing path ${conflictingPath}`,
|
||||
);
|
||||
}
|
||||
existingPaths.push(path);
|
||||
namedRouter.use(path, handler);
|
||||
|
||||
if (indexPath === path) {
|
||||
indexRouter.use(handler);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.replace(/\/*$/, '/');
|
||||
}
|
||||
|
||||
export function findConflictingPath(
|
||||
paths: string[],
|
||||
newPath: string,
|
||||
): string | undefined {
|
||||
const normalizedNewPath = normalizePath(newPath);
|
||||
for (const path of paths) {
|
||||
const normalizedPath = normalizePath(path);
|
||||
if (normalizedPath.startsWith(normalizedNewPath)) {
|
||||
return path;
|
||||
}
|
||||
if (normalizedNewPath.startsWith(normalizedPath)) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -17,8 +17,8 @@
|
||||
import {
|
||||
BackendFeature,
|
||||
ExtensionPoint,
|
||||
ServiceFactory,
|
||||
ServiceRef,
|
||||
ServiceFactoryOrFunction,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
|
||||
/**
|
||||
@@ -42,7 +42,7 @@ export interface BackendRegisterInit {
|
||||
* @public
|
||||
*/
|
||||
export interface CreateSpecializedBackendOptions {
|
||||
services: (ServiceFactory | (() => ServiceFactory))[];
|
||||
services: ServiceFactoryOrFunction[];
|
||||
}
|
||||
|
||||
export interface ServiceHolder {
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"test:kubernetes": "backstage-cli package test -t KubernetesContainerRunner --no-watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-app-api": "workspace:^",
|
||||
"@backstage/backend-plugin-api": "workspace:^",
|
||||
"@backstage/cli-common": "workspace:^",
|
||||
"@backstage/config": "workspace:^",
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import { PluginEndpointDiscovery } from './types';
|
||||
import { readBaseOptions } from '../service/lib/config';
|
||||
import { DEFAULT_PORT } from '../service/lib/ServiceBuilderImpl';
|
||||
import { readHttpServerOptions } from '@backstage/backend-app-api';
|
||||
|
||||
/**
|
||||
* SingleHostDiscovery is a basic PluginEndpointDiscovery implementation
|
||||
@@ -43,9 +42,9 @@ export class SingleHostDiscovery implements PluginEndpointDiscovery {
|
||||
const basePath = options?.basePath ?? '/api';
|
||||
const externalBaseUrl = config.getString('backend.baseUrl');
|
||||
|
||||
const { listenHost = '::', listenPort = DEFAULT_PORT } = readBaseOptions(
|
||||
config.getConfig('backend'),
|
||||
);
|
||||
const {
|
||||
listen: { host: listenHost = '::', port: listenPort },
|
||||
} = readHttpServerOptions(config.getConfig('backend'));
|
||||
const protocol = config.has('backend.https') ? 'https' : 'http';
|
||||
|
||||
// Translate bind-all to localhost, and support IPv6
|
||||
|
||||
@@ -14,19 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
AuthenticationError,
|
||||
ConflictError,
|
||||
ErrorResponseBody,
|
||||
InputError,
|
||||
NotAllowedError,
|
||||
NotFoundError,
|
||||
NotModifiedError,
|
||||
serializeError,
|
||||
} from '@backstage/errors';
|
||||
import { ErrorRequestHandler, NextFunction, Request, Response } from 'express';
|
||||
import { ErrorRequestHandler } from 'express';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { getRootLogger } from '../logging';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { MiddlewareFactory } from '@backstage/backend-app-api';
|
||||
|
||||
/**
|
||||
* Options passed to the {@link errorHandler} middleware.
|
||||
@@ -73,69 +65,11 @@ export type ErrorHandlerOptions = {
|
||||
export function errorHandler(
|
||||
options: ErrorHandlerOptions = {},
|
||||
): ErrorRequestHandler {
|
||||
const showStackTraces =
|
||||
options.showStackTraces ?? process.env.NODE_ENV === 'development';
|
||||
|
||||
const logger = (options.logger || getRootLogger()).child({
|
||||
type: 'errorHandler',
|
||||
return MiddlewareFactory.create({
|
||||
config: new ConfigReader({}),
|
||||
logger: options.logger ?? getRootLogger(),
|
||||
}).error({
|
||||
logAllErrors: options.logClientErrors,
|
||||
showStackTraces: options.showStackTraces,
|
||||
});
|
||||
|
||||
return (error: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
const statusCode = getStatusCode(error);
|
||||
if (options.logClientErrors || statusCode >= 500) {
|
||||
logger.error(`Request failed with status ${statusCode}`, error);
|
||||
}
|
||||
|
||||
if (res.headersSent) {
|
||||
// If the headers have already been sent, do not send the response again
|
||||
// as this will throw an error in the backend.
|
||||
next(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const body: ErrorResponseBody = {
|
||||
error: serializeError(error, { includeStack: showStackTraces }),
|
||||
request: { method: req.method, url: req.url },
|
||||
response: { statusCode },
|
||||
};
|
||||
|
||||
res.status(statusCode).json(body);
|
||||
};
|
||||
}
|
||||
|
||||
function getStatusCode(error: Error): number {
|
||||
// Look for common http library status codes
|
||||
const knownStatusCodeFields = ['statusCode', 'status'];
|
||||
for (const field of knownStatusCodeFields) {
|
||||
const statusCode = (error as any)[field];
|
||||
if (
|
||||
typeof statusCode === 'number' &&
|
||||
(statusCode | 0) === statusCode && // is whole integer
|
||||
statusCode >= 100 &&
|
||||
statusCode <= 599
|
||||
) {
|
||||
return statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle well-known error types
|
||||
switch (error.name) {
|
||||
case NotModifiedError.name:
|
||||
return 304;
|
||||
case InputError.name:
|
||||
return 400;
|
||||
case AuthenticationError.name:
|
||||
return 401;
|
||||
case NotAllowedError.name:
|
||||
return 403;
|
||||
case NotFoundError.name:
|
||||
return 404;
|
||||
case ConflictError.name:
|
||||
return 409;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Fall back to internal server error
|
||||
return 500;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
import { MiddlewareFactory } from '@backstage/backend-app-api';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { RequestHandler } from 'express';
|
||||
import { getRootLogger } from '../logging';
|
||||
|
||||
/**
|
||||
* Express middleware to handle requests for missing routes.
|
||||
@@ -26,8 +29,8 @@ import { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
* @returns An Express request handler
|
||||
*/
|
||||
export function notFoundHandler(): RequestHandler {
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
return (_request: Request, response: Response, _next: NextFunction) => {
|
||||
response.status(404).end();
|
||||
};
|
||||
return MiddlewareFactory.create({
|
||||
config: new ConfigReader({}),
|
||||
logger: getRootLogger(),
|
||||
}).notFound();
|
||||
}
|
||||
|
||||
@@ -14,10 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { MiddlewareFactory } from '@backstage/backend-app-api';
|
||||
import { RequestHandler } from 'express';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import morgan from 'morgan';
|
||||
import { getRootLogger } from '../logging';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
|
||||
/**
|
||||
* Logs incoming requests.
|
||||
@@ -27,15 +28,8 @@ import { getRootLogger } from '../logging';
|
||||
* @returns An Express request handler
|
||||
*/
|
||||
export function requestLoggingHandler(logger?: LoggerService): RequestHandler {
|
||||
const actualLogger = (logger || getRootLogger()).child({
|
||||
type: 'incomingRequest',
|
||||
});
|
||||
|
||||
return morgan('combined', {
|
||||
stream: {
|
||||
write(message: string) {
|
||||
actualLogger.info(message.trimEnd());
|
||||
},
|
||||
},
|
||||
});
|
||||
return MiddlewareFactory.create({
|
||||
config: new ConfigReader({}),
|
||||
logger: logger ?? getRootLogger(),
|
||||
}).logging();
|
||||
}
|
||||
|
||||
@@ -18,10 +18,9 @@ import { Config } from '@backstage/config';
|
||||
import compression from 'compression';
|
||||
import cors from 'cors';
|
||||
import express, { Router, ErrorRequestHandler } from 'express';
|
||||
import helmet from 'helmet';
|
||||
import helmet, { HelmetOptions } from 'helmet';
|
||||
import { ContentSecurityPolicyOptions } from 'helmet/dist/types/middlewares/content-security-policy';
|
||||
import * as http from 'http';
|
||||
import stoppable from 'stoppable';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { useHotCleanup } from '../../hot';
|
||||
import { getRootLogger } from '../../logging';
|
||||
@@ -32,26 +31,20 @@ import {
|
||||
} from '../../middleware';
|
||||
import { RequestLoggingHandlerFactory, ServiceBuilder } from '../types';
|
||||
import {
|
||||
CspOptions,
|
||||
HttpsSettings,
|
||||
readBaseOptions,
|
||||
readCorsOptions,
|
||||
readCspOptions,
|
||||
readHttpsSettings,
|
||||
} from './config';
|
||||
import { createHttpServer, createHttpsServer } from './hostFactory';
|
||||
readHelmetOptions,
|
||||
readHttpServerOptions,
|
||||
HttpServerOptions,
|
||||
createHttpServer,
|
||||
} from '@backstage/backend-app-api';
|
||||
|
||||
export const DEFAULT_PORT = 7007;
|
||||
// '' is express default, which listens to all interfaces
|
||||
const DEFAULT_HOST = '';
|
||||
export type CspOptions = Record<string, string[]>;
|
||||
|
||||
export class ServiceBuilderImpl implements ServiceBuilder {
|
||||
private port: number | undefined;
|
||||
private host: string | undefined;
|
||||
private logger: LoggerService | undefined;
|
||||
private corsOptions: cors.CorsOptions | undefined;
|
||||
private cspOptions: Record<string, string[] | false> | undefined;
|
||||
private httpsSettings: HttpsSettings | undefined;
|
||||
private serverOptions: HttpServerOptions;
|
||||
private helmetOptions: HelmetOptions;
|
||||
private corsOptions: cors.CorsOptions;
|
||||
private routers: [string, Router][];
|
||||
private requestLoggingHandler: RequestLoggingHandlerFactory | undefined;
|
||||
private errorHandler: ErrorRequestHandler | undefined;
|
||||
@@ -64,50 +57,29 @@ export class ServiceBuilderImpl implements ServiceBuilder {
|
||||
this.routers = [];
|
||||
this.module = moduleRef;
|
||||
this.useDefaultErrorHandler = true;
|
||||
|
||||
this.serverOptions = readHttpServerOptions();
|
||||
this.corsOptions = readCorsOptions();
|
||||
this.helmetOptions = readHelmetOptions();
|
||||
}
|
||||
|
||||
loadConfig(config: Config): ServiceBuilder {
|
||||
const backendConfig = config.getOptionalConfig('backend');
|
||||
if (!backendConfig) {
|
||||
return this;
|
||||
}
|
||||
|
||||
const baseOptions = readBaseOptions(backendConfig);
|
||||
if (baseOptions.listenPort) {
|
||||
this.port =
|
||||
typeof baseOptions.listenPort === 'string'
|
||||
? parseInt(baseOptions.listenPort, 10)
|
||||
: baseOptions.listenPort;
|
||||
}
|
||||
if (baseOptions.listenHost) {
|
||||
this.host = baseOptions.listenHost;
|
||||
}
|
||||
|
||||
const corsOptions = readCorsOptions(backendConfig);
|
||||
if (corsOptions) {
|
||||
this.corsOptions = corsOptions;
|
||||
}
|
||||
|
||||
const cspOptions = readCspOptions(backendConfig);
|
||||
if (cspOptions) {
|
||||
this.cspOptions = cspOptions;
|
||||
}
|
||||
|
||||
const httpsSettings = readHttpsSettings(backendConfig);
|
||||
if (httpsSettings) {
|
||||
this.httpsSettings = httpsSettings;
|
||||
}
|
||||
this.serverOptions = readHttpServerOptions(backendConfig);
|
||||
this.corsOptions = readCorsOptions(backendConfig);
|
||||
this.helmetOptions = readHelmetOptions(backendConfig);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
setPort(port: number): ServiceBuilder {
|
||||
this.port = port;
|
||||
this.serverOptions.listen.port = port;
|
||||
return this;
|
||||
}
|
||||
|
||||
setHost(host: string): ServiceBuilder {
|
||||
this.host = host;
|
||||
this.serverOptions.listen.host = host;
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -116,8 +88,24 @@ export class ServiceBuilderImpl implements ServiceBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
setHttpsSettings(settings: HttpsSettings): ServiceBuilder {
|
||||
this.httpsSettings = settings;
|
||||
setHttpsSettings(settings: {
|
||||
certificate: { key: string; cert: string } | { hostname: string };
|
||||
}): ServiceBuilder {
|
||||
if ('hostname' in settings.certificate) {
|
||||
this.serverOptions.https = {
|
||||
certificate: {
|
||||
...settings.certificate,
|
||||
type: 'generated',
|
||||
},
|
||||
};
|
||||
} else {
|
||||
this.serverOptions.https = {
|
||||
certificate: {
|
||||
...settings.certificate,
|
||||
type: 'plain',
|
||||
},
|
||||
};
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -127,7 +115,11 @@ export class ServiceBuilderImpl implements ServiceBuilder {
|
||||
}
|
||||
|
||||
setCsp(options: CspOptions): ServiceBuilder {
|
||||
this.cspOptions = options;
|
||||
const csp = this.helmetOptions.contentSecurityPolicy;
|
||||
this.helmetOptions.contentSecurityPolicy = {
|
||||
...(typeof csp === 'object' ? csp : {}),
|
||||
directives: applyCspDirectives(options),
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -155,13 +147,10 @@ export class ServiceBuilderImpl implements ServiceBuilder {
|
||||
|
||||
async start(): Promise<http.Server> {
|
||||
const app = express();
|
||||
const { port, host, logger, corsOptions, httpsSettings, helmetOptions } =
|
||||
this.getOptions();
|
||||
const logger = this.logger ?? getRootLogger();
|
||||
|
||||
app.use(helmet(helmetOptions));
|
||||
if (corsOptions) {
|
||||
app.use(cors(corsOptions));
|
||||
}
|
||||
app.use(helmet(this.helmetOptions));
|
||||
app.use(cors(this.corsOptions));
|
||||
app.use(compression());
|
||||
app.use(
|
||||
(this.requestLoggingHandler ?? defaultRequestLoggingHandler)(logger),
|
||||
@@ -179,58 +168,23 @@ export class ServiceBuilderImpl implements ServiceBuilder {
|
||||
app.use(defaultErrorHandler());
|
||||
}
|
||||
|
||||
const server: http.Server = httpsSettings
|
||||
? await createHttpsServer(app, httpsSettings, logger)
|
||||
: createHttpServer(app, logger);
|
||||
const stoppableServer = stoppable(server, 0);
|
||||
const server = await createHttpServer(app, this.serverOptions, { logger });
|
||||
|
||||
useHotCleanup(this.module, () =>
|
||||
stoppableServer.stop((e: any) => {
|
||||
if (e) console.error(e);
|
||||
server.stop().catch(error => {
|
||||
console.error(error);
|
||||
}),
|
||||
);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
function handleStartupError(e: unknown) {
|
||||
server.close();
|
||||
reject(e);
|
||||
}
|
||||
await server.start();
|
||||
|
||||
server.on('error', handleStartupError);
|
||||
|
||||
server.listen(port, host, () => {
|
||||
server.off('error', handleStartupError);
|
||||
logger.info(`Listening on ${host}:${port}`);
|
||||
resolve(stoppableServer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private getOptions() {
|
||||
return {
|
||||
port: this.port ?? DEFAULT_PORT,
|
||||
host: this.host ?? DEFAULT_HOST,
|
||||
logger: this.logger ?? getRootLogger(),
|
||||
corsOptions: this.corsOptions,
|
||||
httpsSettings: this.httpsSettings,
|
||||
helmetOptions: {
|
||||
contentSecurityPolicy: {
|
||||
useDefaults: false,
|
||||
directives: applyCspDirectives(this.cspOptions),
|
||||
},
|
||||
// These are all disabled in order to maintain backwards compatibility
|
||||
// when bumping helmet v5. We can't enable these by default because
|
||||
// there is no way for users to configure them.
|
||||
// TODO(Rugvip): We should give control of this setup to consumers
|
||||
crossOriginEmbedderPolicy: false,
|
||||
crossOriginOpenerPolicy: false,
|
||||
crossOriginResourcePolicy: false,
|
||||
originAgentCluster: false,
|
||||
},
|
||||
};
|
||||
return server;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(Rugvip): This is a duplicate of the same logic over in backend-app-api.
|
||||
// It's needed as we don't want to export this helper from there, but need
|
||||
// It to implement the setCsp method here.
|
||||
export function applyCspDirectives(
|
||||
directives: Record<string, string[] | false> | undefined,
|
||||
): ContentSecurityPolicyOptions['directives'] {
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
/*
|
||||
* 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 { ConfigReader } from '@backstage/config';
|
||||
import { readCorsOptions, readCspOptions } from './config';
|
||||
|
||||
describe('config', () => {
|
||||
describe('readCspOptions', () => {
|
||||
it('reads valid values', () => {
|
||||
const config = new ConfigReader({ csp: { key: ['value'] } });
|
||||
expect(readCspOptions(config)).toEqual(
|
||||
expect.objectContaining({
|
||||
key: ['value'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts false', () => {
|
||||
const config = new ConfigReader({ csp: { key: false } });
|
||||
expect(readCspOptions(config)).toEqual(
|
||||
expect.objectContaining({
|
||||
key: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid value types', () => {
|
||||
const config = new ConfigReader({ csp: { key: [4] } });
|
||||
expect(() => readCspOptions(config)).toThrow(/wanted string-array/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readCorsOptions', () => {
|
||||
it('reads single string', () => {
|
||||
const mockCallback = jest.fn();
|
||||
const config = new ConfigReader({ cors: { origin: 'https://*.value*' } });
|
||||
const cors = readCorsOptions(config);
|
||||
expect(cors).toEqual(
|
||||
expect.objectContaining({
|
||||
origin: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
const origin = cors?.origin as Function;
|
||||
origin('https://a.value', mockCallback); // valid origin
|
||||
origin('http://a.value', mockCallback); // invalid origin
|
||||
origin(undefined, mockCallback); // when not origin needs to reject the call
|
||||
|
||||
expect(mockCallback.mock.calls[0][0]).toBe(null);
|
||||
expect(mockCallback.mock.calls[1][0]).toBe(null);
|
||||
|
||||
expect(mockCallback.mock.calls[0][1]).toBe(true);
|
||||
expect(mockCallback.mock.calls[1][1]).toBe(false);
|
||||
expect(mockCallback.mock.calls[2][1]).toBe(false);
|
||||
});
|
||||
|
||||
it('reads string array', () => {
|
||||
const mockCallback = jest.fn();
|
||||
const config = new ConfigReader({
|
||||
cors: {
|
||||
origin: ['http?(s)://*.value?(-+([0-9])).com', 'http://*.value'],
|
||||
},
|
||||
});
|
||||
const cors = readCorsOptions(config);
|
||||
expect(cors).toEqual(
|
||||
expect.objectContaining({
|
||||
origin: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
const origin = cors?.origin as Function;
|
||||
origin('https://a.b.c.value-9.com', mockCallback);
|
||||
origin('http://a.value-999.com', mockCallback);
|
||||
origin('http://a.value', mockCallback);
|
||||
origin('http://a.valuex', mockCallback);
|
||||
|
||||
expect(mockCallback.mock.calls[0][0]).toBe(null);
|
||||
expect(mockCallback.mock.calls[1][0]).toBe(null);
|
||||
expect(mockCallback.mock.calls[2][0]).toBe(null);
|
||||
expect(mockCallback.mock.calls[3][0]).toBe(null);
|
||||
|
||||
expect(mockCallback.mock.calls[0][1]).toBe(true);
|
||||
expect(mockCallback.mock.calls[1][1]).toBe(true);
|
||||
expect(mockCallback.mock.calls[2][1]).toBe(true);
|
||||
expect(mockCallback.mock.calls[3][1]).toBe(false);
|
||||
});
|
||||
|
||||
it('reads undefined origin', () => {
|
||||
const config = new ConfigReader({
|
||||
cors: {},
|
||||
});
|
||||
const cors = readCorsOptions(config);
|
||||
expect(cors).toEqual(expect.objectContaining({}));
|
||||
expect(cors?.origin).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,284 +0,0 @@
|
||||
/*
|
||||
* 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 { Config } from '@backstage/config';
|
||||
import { CorsOptions } from 'cors';
|
||||
import { Minimatch } from 'minimatch';
|
||||
|
||||
export type BaseOptions = {
|
||||
listenPort?: string | number;
|
||||
listenHost?: string;
|
||||
};
|
||||
|
||||
export type HttpsSettings = {
|
||||
certificate: CertificateGenerationOptions | CertificateReferenceOptions;
|
||||
};
|
||||
|
||||
export type CertificateReferenceOptions = {
|
||||
key: string;
|
||||
cert: string;
|
||||
};
|
||||
|
||||
export type CertificateGenerationOptions = {
|
||||
hostname: string;
|
||||
};
|
||||
|
||||
export type CertificateAttributes = {
|
||||
commonName: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A map from CSP directive names to their values.
|
||||
*/
|
||||
export type CspOptions = Record<string, string[]>;
|
||||
|
||||
type StaticOrigin = boolean | string | RegExp | (boolean | string | RegExp)[];
|
||||
|
||||
type CustomOrigin = (
|
||||
requestOrigin: string | undefined,
|
||||
callback: (err: Error | null, origin?: StaticOrigin) => void,
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Reads some base options out of a config object.
|
||||
*
|
||||
* @param config - The root of a backend config object
|
||||
* @returns A base options object
|
||||
*
|
||||
* @example
|
||||
* ```json
|
||||
* {
|
||||
* baseUrl: "http://localhost:7007",
|
||||
* listen: "0.0.0.0:7007"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function readBaseOptions(config: Config): BaseOptions {
|
||||
if (typeof config.get('listen') === 'string') {
|
||||
// TODO(freben): Expand this to support more addresses and perhaps optional
|
||||
const { host, port } = parseListenAddress(config.getString('listen'));
|
||||
|
||||
return removeUnknown({
|
||||
listenPort: port,
|
||||
listenHost: host,
|
||||
});
|
||||
}
|
||||
|
||||
const port = config.getOptional('listen.port');
|
||||
if (
|
||||
typeof port !== 'undefined' &&
|
||||
typeof port !== 'number' &&
|
||||
typeof port !== 'string'
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid type in config for key 'backend.listen.port', got ${typeof port}, wanted string or number`,
|
||||
);
|
||||
}
|
||||
|
||||
return removeUnknown({
|
||||
listenPort: port,
|
||||
listenHost: config.getOptionalString('listen.host'),
|
||||
baseUrl: config.getOptionalString('baseUrl'),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to read a CORS options object from the root of a config object.
|
||||
*
|
||||
* @param config - The root of a backend config object
|
||||
* @returns A CORS options object, or undefined if not specified
|
||||
*
|
||||
* @example
|
||||
* ```json
|
||||
* {
|
||||
* cors: {
|
||||
* origin: "http://localhost:3000",
|
||||
* credentials: true
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function readCorsOptions(config: Config): CorsOptions | undefined {
|
||||
const cc = config.getOptionalConfig('cors');
|
||||
if (!cc) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return removeUnknown({
|
||||
origin: createCorsOriginMatcher(getOptionalStringOrStrings(cc, 'origin')),
|
||||
methods: getOptionalStringOrStrings(cc, 'methods'),
|
||||
allowedHeaders: getOptionalStringOrStrings(cc, 'allowedHeaders'),
|
||||
exposedHeaders: getOptionalStringOrStrings(cc, 'exposedHeaders'),
|
||||
credentials: cc.getOptionalBoolean('credentials'),
|
||||
maxAge: cc.getOptionalNumber('maxAge'),
|
||||
preflightContinue: cc.getOptionalBoolean('preflightContinue'),
|
||||
optionsSuccessStatus: cc.getOptionalNumber('optionsSuccessStatus'),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to read a CSP options object from the root of a config object.
|
||||
*
|
||||
* @param config - The root of a backend config object
|
||||
* @returns A CSP options object, or undefined if not specified. Values can be
|
||||
* false as well, which means to remove the default behavior for that
|
||||
* key.
|
||||
*
|
||||
* @example
|
||||
* ```yaml
|
||||
* backend:
|
||||
* csp:
|
||||
* connect-src: ["'self'", 'http:', 'https:']
|
||||
* upgrade-insecure-requests: false
|
||||
* ```
|
||||
*/
|
||||
export function readCspOptions(
|
||||
config: Config,
|
||||
): Record<string, string[] | false> | undefined {
|
||||
const cc = config.getOptionalConfig('csp');
|
||||
if (!cc) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result: Record<string, string[] | false> = {};
|
||||
for (const key of cc.keys()) {
|
||||
if (cc.get(key) === false) {
|
||||
result[key] = false;
|
||||
} else {
|
||||
result[key] = cc.getStringArray(key);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to read a https settings object from the root of a config object.
|
||||
*
|
||||
* @param config - The root of a backend config object
|
||||
* @returns A https settings object, or undefined if not specified
|
||||
*
|
||||
* @example
|
||||
* ```json
|
||||
* {
|
||||
* https: {
|
||||
* certificate: ...
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function readHttpsSettings(config: Config): HttpsSettings | undefined {
|
||||
const https = config.getOptional('https');
|
||||
if (https === true) {
|
||||
const baseUrl = config.getString('baseUrl');
|
||||
let hostname;
|
||||
try {
|
||||
hostname = new URL(baseUrl).hostname;
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid backend.baseUrl "${baseUrl}"`);
|
||||
}
|
||||
|
||||
return { certificate: { hostname } };
|
||||
}
|
||||
|
||||
const cc = config.getOptionalConfig('https');
|
||||
if (!cc) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const certificateConfig = cc.get('certificate');
|
||||
|
||||
const cfg = {
|
||||
certificate: certificateConfig,
|
||||
};
|
||||
|
||||
return removeUnknown(cfg as HttpsSettings);
|
||||
}
|
||||
|
||||
function getOptionalStringOrStrings(
|
||||
config: Config,
|
||||
key: string,
|
||||
): string | string[] | undefined {
|
||||
const value = config.getOptional(key);
|
||||
if (value === undefined || isStringOrStrings(value)) {
|
||||
return value;
|
||||
}
|
||||
throw new Error(`Expected string or array of strings, got ${typeof value}`);
|
||||
}
|
||||
|
||||
function createCorsOriginMatcher(
|
||||
originValue: string | string[] | undefined,
|
||||
): CustomOrigin | undefined {
|
||||
if (originValue === undefined) {
|
||||
return originValue;
|
||||
}
|
||||
|
||||
if (!isStringOrStrings(originValue)) {
|
||||
throw new Error(
|
||||
`Expected string or array of strings, got ${typeof originValue}`,
|
||||
);
|
||||
}
|
||||
|
||||
const allowedOrigin =
|
||||
typeof originValue === 'string' ? [originValue] : originValue;
|
||||
|
||||
const allowedOriginPatterns =
|
||||
allowedOrigin?.map(
|
||||
pattern => new Minimatch(pattern, { nocase: true, noglobstar: true }),
|
||||
) ?? [];
|
||||
|
||||
return (origin, callback) => {
|
||||
return callback(
|
||||
null,
|
||||
allowedOriginPatterns.some(pattern => pattern.match(origin ?? '')),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function isStringOrStrings(value: any): value is string | string[] {
|
||||
return typeof value === 'string' || isStringArray(value);
|
||||
}
|
||||
|
||||
function isStringArray(value: any): value is string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
for (const v of value) {
|
||||
if (typeof v !== 'string') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function removeUnknown<T extends object>(obj: T): T {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).filter(([, v]) => v !== undefined),
|
||||
) as T;
|
||||
}
|
||||
|
||||
function parseListenAddress(value: string): { host?: string; port?: number } {
|
||||
const parts = value.split(':');
|
||||
if (parts.length === 1) {
|
||||
return { port: parseInt(parts[0], 10) };
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return { host: parts[0], port: parseInt(parts[1], 10) };
|
||||
}
|
||||
throw new Error(
|
||||
`Unable to parse listen address ${value}, expected <port> or <host>:<port>`,
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
```ts
|
||||
import { Backend } from '@backstage/backend-app-api';
|
||||
import { ServiceFactory } from '@backstage/backend-plugin-api';
|
||||
import { ServiceFactoryOrFunction } from '@backstage/backend-plugin-api';
|
||||
|
||||
// @public (undocumented)
|
||||
export function createBackend(options?: CreateBackendOptions): Backend;
|
||||
@@ -12,6 +12,6 @@ export function createBackend(options?: CreateBackendOptions): Backend;
|
||||
// @public (undocumented)
|
||||
export interface CreateBackendOptions {
|
||||
// (undocumented)
|
||||
services?: (ServiceFactory | (() => ServiceFactory))[];
|
||||
services?: ServiceFactoryOrFunction[];
|
||||
}
|
||||
```
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
tokenManagerFactory,
|
||||
urlReaderFactory,
|
||||
} from '@backstage/backend-app-api';
|
||||
import { ServiceFactory } from '@backstage/backend-plugin-api';
|
||||
import { ServiceFactoryOrFunction } from '@backstage/backend-plugin-api';
|
||||
|
||||
export const defaultServiceFactories = [
|
||||
cacheFactory(),
|
||||
@@ -55,7 +55,7 @@ export const defaultServiceFactories = [
|
||||
* @public
|
||||
*/
|
||||
export interface CreateBackendOptions {
|
||||
services?: (ServiceFactory | (() => ServiceFactory))[];
|
||||
services?: ServiceFactoryOrFunction[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -347,6 +347,11 @@ export interface ServiceFactoryConfig<
|
||||
service: ServiceRef<TService, TScope>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type ServiceFactoryOrFunction<TService = unknown> =
|
||||
| ServiceFactory<TService>
|
||||
| (() => ServiceFactory<TService>);
|
||||
|
||||
// @public
|
||||
export type ServiceRef<
|
||||
TService,
|
||||
@@ -364,7 +369,7 @@ export interface ServiceRefConfig<TService, TScope extends 'root' | 'plugin'> {
|
||||
// (undocumented)
|
||||
defaultFactory?: (
|
||||
service: ServiceRef<TService, TScope>,
|
||||
) => Promise<ServiceFactory<TService> | (() => ServiceFactory<TService>)>;
|
||||
) => Promise<ServiceFactoryOrFunction<TService>>;
|
||||
// (undocumented)
|
||||
id: string;
|
||||
// (undocumented)
|
||||
|
||||
@@ -20,5 +20,6 @@ export type {
|
||||
TypesToServiceRef,
|
||||
ServiceFactory,
|
||||
ServiceFactoryConfig,
|
||||
ServiceFactoryOrFunction,
|
||||
} from './types';
|
||||
export { createServiceRef, createServiceFactory } from './types';
|
||||
|
||||
@@ -71,13 +71,22 @@ export type ServiceFactory<TService = unknown> =
|
||||
>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents either a {@link ServiceFactory} or a function that returns one.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ServiceFactoryOrFunction<TService = unknown> =
|
||||
| ServiceFactory<TService>
|
||||
| (() => ServiceFactory<TService>);
|
||||
|
||||
/** @public */
|
||||
export interface ServiceRefConfig<TService, TScope extends 'root' | 'plugin'> {
|
||||
id: string;
|
||||
scope?: TScope;
|
||||
defaultFactory?: (
|
||||
service: ServiceRef<TService, TScope>,
|
||||
) => Promise<ServiceFactory<TService> | (() => ServiceFactory<TService>)>;
|
||||
) => Promise<ServiceFactoryOrFunction<TService>>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,12 +18,12 @@ import { resolvePackagePath } from '@backstage/backend-common';
|
||||
import { Knex } from 'knex';
|
||||
import { DB_MIGRATIONS_TABLE } from './tables';
|
||||
|
||||
const migrationsDir = resolvePackagePath(
|
||||
'@backstage/backend-tasks',
|
||||
'migrations',
|
||||
);
|
||||
|
||||
export async function migrateBackendTasks(knex: Knex): Promise<void> {
|
||||
const migrationsDir = resolvePackagePath(
|
||||
'@backstage/backend-tasks',
|
||||
'migrations',
|
||||
);
|
||||
|
||||
await knex.migrate.latest({
|
||||
directory: migrationsDir,
|
||||
tableName: DB_MIGRATIONS_TABLE,
|
||||
|
||||
@@ -100,6 +100,8 @@ Options:
|
||||
--no-docker
|
||||
--mkdocs-port <PORT>
|
||||
-v --verbose
|
||||
--preview-app-bundle-path <PATH_TO_BUNDLE>
|
||||
--preview-app-port <PORT>
|
||||
-h, --help
|
||||
```
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Command } from 'commander';
|
||||
import { TechdocsGenerator } from '@backstage/plugin-techdocs-node';
|
||||
|
||||
const defaultDockerImage = TechdocsGenerator.defaultDockerImage;
|
||||
const defaultPreviewAppPort = '3000';
|
||||
|
||||
export function registerCommands(program: Command) {
|
||||
program
|
||||
@@ -251,6 +252,25 @@ export function registerCommands(program: Command) {
|
||||
)
|
||||
.option('--mkdocs-port <PORT>', 'Port for MkDocs server to use', '8000')
|
||||
.option('-v --verbose', 'Enable verbose output.', false)
|
||||
.option(
|
||||
'--preview-app-bundle-path <PATH_TO_BUNDLE>',
|
||||
'Preview documentation using another web app',
|
||||
)
|
||||
.option(
|
||||
'--preview-app-port <PORT>',
|
||||
'Port for the preview app to be served on',
|
||||
defaultPreviewAppPort,
|
||||
)
|
||||
.hook('preAction', command => {
|
||||
if (
|
||||
command.opts().previewAppPort !== defaultPreviewAppPort &&
|
||||
!command.opts().previewAppBundlePath
|
||||
) {
|
||||
command.error(
|
||||
'--preview-app-port can only be used together with --preview-app-bundle-path',
|
||||
);
|
||||
}
|
||||
})
|
||||
.action(lazy(() => import('./serve/serve').then(m => m.default)));
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,10 @@ function findPreviewBundlePath(): string {
|
||||
}
|
||||
}
|
||||
|
||||
function getPreviewAppPath(opts: OptionValues): string {
|
||||
return opts.previewAppBundlePath ?? findPreviewBundlePath();
|
||||
}
|
||||
|
||||
export default async function serve(opts: OptionValues) {
|
||||
const logger = createLogger({ verbose: opts.verbose });
|
||||
|
||||
@@ -52,10 +56,6 @@ export default async function serve(opts: OptionValues) {
|
||||
? true
|
||||
: false;
|
||||
|
||||
// TODO: Backstage app port should also be configurable as a CLI option. However, since we bundle
|
||||
// a backstage app, we define app.baseUrl in the app-config.yaml.
|
||||
// Hence, it is complicated to make this configurable.
|
||||
const backstagePort = 3000;
|
||||
const backstageBackendPort = 7007;
|
||||
|
||||
const mkdocsDockerAddr = `http://0.0.0.0:${opts.mkdocsPort}`;
|
||||
@@ -115,9 +115,10 @@ export default async function serve(opts: OptionValues) {
|
||||
);
|
||||
}
|
||||
|
||||
const port = isDevMode ? backstageBackendPort : backstagePort;
|
||||
const port = isDevMode ? backstageBackendPort : opts.previewAppPort;
|
||||
const previewAppPath = getPreviewAppPath(opts);
|
||||
const httpServer = new HTTPServer(
|
||||
findPreviewBundlePath(),
|
||||
previewAppPath,
|
||||
port,
|
||||
opts.mkdocsPort,
|
||||
opts.verbose,
|
||||
@@ -126,7 +127,7 @@ export default async function serve(opts: OptionValues) {
|
||||
httpServer
|
||||
.serve()
|
||||
.catch(err => {
|
||||
logger.error(err);
|
||||
logger.error('Failed to start HTTP server', err);
|
||||
mkdocsChildProcess.kill();
|
||||
process.exit(1);
|
||||
})
|
||||
|
||||
@@ -131,7 +131,7 @@ export class AwsS3EntityProvider implements EntityProvider {
|
||||
try {
|
||||
await this.refresh(logger);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
logger.error(`${this.getProviderName()} refresh failed`, error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -113,7 +113,7 @@ export class AzureDevOpsEntityProvider implements EntityProvider {
|
||||
try {
|
||||
await this.refresh(logger);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
logger.error(`${this.getProviderName()} refresh failed`, error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -152,7 +152,7 @@ export class BitbucketCloudEntityProvider
|
||||
try {
|
||||
await this.refresh(logger);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
logger.error(`${this.getProviderName()} refresh failed`, error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
+1
-1
@@ -130,7 +130,7 @@ export class BitbucketServerEntityProvider implements EntityProvider {
|
||||
try {
|
||||
await this.refresh(logger);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
logger.error(`${this.getProviderName()} refresh failed`, error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -129,7 +129,7 @@ export class GerritEntityProvider implements EntityProvider {
|
||||
try {
|
||||
await this.refresh(logger);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
logger.error(`${this.getProviderName()} refresh failed`, error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -157,7 +157,7 @@ export class GithubEntityProvider implements EntityProvider, EventSubscriber {
|
||||
try {
|
||||
await this.refresh(logger);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
logger.error(`${this.getProviderName()} refresh failed`, error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -565,7 +565,7 @@ export class GithubOrgEntityProvider
|
||||
try {
|
||||
await this.read({ logger });
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
logger.error(`${this.getProviderName()} refresh failed`, error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
+1
-1
@@ -133,7 +133,7 @@ export class GitlabDiscoveryEntityProvider implements EntityProvider {
|
||||
try {
|
||||
await this.refresh(logger);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
logger.error(`${this.getProviderName()} refresh failed`, error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
+4
-1
@@ -111,7 +111,10 @@ export class IncrementalIngestionEngine implements IterationEngine {
|
||||
);
|
||||
|
||||
const backoffLength = currentBackoff.as('milliseconds');
|
||||
this.options.logger.error(error);
|
||||
this.options.logger.error(
|
||||
`incremental-engine: Ingestion '${ingestionId}' failed`,
|
||||
error,
|
||||
);
|
||||
|
||||
const truncatedError = stringifyError(error).substring(0, 700);
|
||||
this.options.logger.error(
|
||||
|
||||
@@ -225,7 +225,7 @@ export class LdapOrgEntityProvider implements EntityProvider {
|
||||
try {
|
||||
await this.read({ logger });
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
logger.error(`${this.getProviderName()} refresh failed`, error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
+1
-1
@@ -351,7 +351,7 @@ export class MicrosoftGraphOrgEntityProvider implements EntityProvider {
|
||||
try {
|
||||
await this.read({ logger });
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
logger.error(`${this.getProviderName()} refresh failed`, error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ export class AwsSqsConsumingEventPublisher implements EventPublisher {
|
||||
await this.sleep(this.waitTimeAfterEmptyReceiveMs);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
logger.error('Failed to consume AWS SQS messages', error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -855,6 +855,10 @@ export class TaskWorker {
|
||||
export type TemplateAction<Input extends JsonObject> = {
|
||||
id: string;
|
||||
description?: string;
|
||||
examples?: {
|
||||
description: string;
|
||||
example: string;
|
||||
}[];
|
||||
supportsDryRun?: boolean;
|
||||
schema?: {
|
||||
input?: Schema;
|
||||
|
||||
@@ -19,6 +19,28 @@ import { ScmIntegrations } from '@backstage/integration';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import { createTemplateAction } from '../../createTemplateAction';
|
||||
import yaml from 'yaml';
|
||||
|
||||
const id = 'catalog:register';
|
||||
|
||||
const examples = [
|
||||
{
|
||||
description: 'Register with the catalog',
|
||||
example: yaml.stringify({
|
||||
steps: [
|
||||
{
|
||||
action: id,
|
||||
id: 'register-with-catalog',
|
||||
name: 'Register with the catalog',
|
||||
input: {
|
||||
catalogInfoUrl:
|
||||
'http://github.com/backstage/backstage/blob/master/catalog-info.yaml',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Registers entities from a catalog descriptor file in the workspace into the software catalog.
|
||||
@@ -34,9 +56,10 @@ export function createCatalogRegisterAction(options: {
|
||||
| { catalogInfoUrl: string; optional?: boolean }
|
||||
| { repoContentsUrl: string; catalogInfoPath?: string; optional?: boolean }
|
||||
>({
|
||||
id: 'catalog:register',
|
||||
id,
|
||||
description:
|
||||
'Registers entities from a catalog descriptor file in the workspace into the software catalog.',
|
||||
examples,
|
||||
schema: {
|
||||
input: {
|
||||
oneOf: [
|
||||
|
||||
@@ -20,14 +20,47 @@ import * as yaml from 'yaml';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { resolveSafeChildPath } from '@backstage/backend-common';
|
||||
|
||||
const id = 'catalog:write';
|
||||
|
||||
const examples = [
|
||||
{
|
||||
description: 'Write a catalog yaml file',
|
||||
example: yaml.stringify({
|
||||
steps: [
|
||||
{
|
||||
action: id,
|
||||
id: 'create-catalog-info-file',
|
||||
name: 'Create catalog file',
|
||||
input: {
|
||||
entity: {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'test',
|
||||
annotations: {},
|
||||
},
|
||||
spec: {
|
||||
type: 'service',
|
||||
lifecycle: 'production',
|
||||
owner: 'default/owner',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Writes a catalog descriptor file containing the provided entity to a path in the workspace.
|
||||
* @public
|
||||
*/
|
||||
export function createCatalogWriteAction() {
|
||||
return createTemplateAction<{ filePath?: string; entity: Entity }>({
|
||||
id: 'catalog:write',
|
||||
id,
|
||||
description: 'Writes the catalog-info.yaml for your template',
|
||||
examples,
|
||||
schema: {
|
||||
input: {
|
||||
type: 'object',
|
||||
|
||||
@@ -20,6 +20,7 @@ import os from 'os';
|
||||
import { Writable } from 'stream';
|
||||
import { createDebugLogAction } from './log';
|
||||
import { join } from 'path';
|
||||
import yaml from 'yaml';
|
||||
|
||||
describe('debug:log', () => {
|
||||
const logStream = {
|
||||
@@ -91,4 +92,43 @@ describe('debug:log', () => {
|
||||
expect.stringContaining('Hello Backstage!'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log the workspace content from an example, if active', async () => {
|
||||
const example = action.examples?.find(
|
||||
sample => sample.description === 'List the workspace directory',
|
||||
)?.example as string;
|
||||
expect(typeof example).toEqual('string');
|
||||
const context = {
|
||||
...mockContext,
|
||||
...yaml.parse(example).steps[0],
|
||||
};
|
||||
|
||||
await action.handler(context);
|
||||
|
||||
expect(logStream.write).toHaveBeenCalledTimes(1);
|
||||
expect(logStream.write).toHaveBeenCalledWith(
|
||||
expect.stringContaining('README.md'),
|
||||
);
|
||||
expect(logStream.write).toHaveBeenCalledWith(
|
||||
expect.stringContaining(join('a-directory', 'index.md')),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log message from an example', async () => {
|
||||
const example = action.examples?.find(
|
||||
sample => sample.description === 'Write a debug message',
|
||||
)?.example as string;
|
||||
|
||||
const context = {
|
||||
...mockContext,
|
||||
...yaml.parse(example).steps[0],
|
||||
};
|
||||
|
||||
await action.handler(context);
|
||||
|
||||
expect(logStream.write).toHaveBeenCalledTimes(1);
|
||||
expect(logStream.write).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Hello Backstage!'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,42 @@
|
||||
import { readdir, stat } from 'fs-extra';
|
||||
import { relative, join } from 'path';
|
||||
import { createTemplateAction } from '../../createTemplateAction';
|
||||
import yaml from 'yaml';
|
||||
|
||||
const id = 'debug:log';
|
||||
|
||||
const examples = [
|
||||
{
|
||||
description: 'Write a debug message',
|
||||
example: yaml.stringify({
|
||||
steps: [
|
||||
{
|
||||
action: id,
|
||||
id: 'write-debug-line',
|
||||
name: 'Write "Hello Backstage!" log line',
|
||||
input: {
|
||||
message: 'Hello Backstage!',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
description: 'List the workspace directory',
|
||||
example: yaml.stringify({
|
||||
steps: [
|
||||
{
|
||||
action: id,
|
||||
id: 'write-workspace-directory',
|
||||
name: 'List the workspace directory',
|
||||
input: {
|
||||
listWorkspace: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Writes a message into the log or lists all files in the workspace
|
||||
@@ -30,9 +66,10 @@ import { createTemplateAction } from '../../createTemplateAction';
|
||||
*/
|
||||
export function createDebugLogAction() {
|
||||
return createTemplateAction<{ message?: string; listWorkspace?: boolean }>({
|
||||
id: 'debug:log',
|
||||
id,
|
||||
description:
|
||||
'Writes a message into the log or lists all files in the workspace.',
|
||||
examples,
|
||||
schema: {
|
||||
input: {
|
||||
type: 'object',
|
||||
|
||||
@@ -66,6 +66,7 @@ export type ActionContext<Input extends JsonObject> = {
|
||||
export type TemplateAction<Input extends JsonObject> = {
|
||||
id: string;
|
||||
description?: string;
|
||||
examples?: { description: string; example: string }[];
|
||||
supportsDryRun?: boolean;
|
||||
schema?: {
|
||||
input?: Schema;
|
||||
|
||||
@@ -292,6 +292,7 @@ export async function createRouter(
|
||||
return {
|
||||
id: action.id,
|
||||
description: action.description,
|
||||
examples: action.examples,
|
||||
schema: action.schema,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -28,6 +28,23 @@ import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
import { UIOptionsType } from '@rjsf/utils';
|
||||
import { UiSchema } from '@rjsf/utils';
|
||||
|
||||
// @public
|
||||
export type Action = {
|
||||
id: string;
|
||||
description?: string;
|
||||
schema?: {
|
||||
input?: JSONSchema7;
|
||||
output?: JSONSchema7;
|
||||
};
|
||||
examples?: ActionExample[];
|
||||
};
|
||||
|
||||
// @public
|
||||
export type ActionExample = {
|
||||
description: string;
|
||||
example: string;
|
||||
};
|
||||
|
||||
// @alpha
|
||||
export const createFieldValidation: () => FieldValidation_2;
|
||||
|
||||
@@ -102,14 +119,7 @@ export type FormProps = Pick<
|
||||
>;
|
||||
|
||||
// @public
|
||||
export type ListActionsResponse = Array<{
|
||||
id: string;
|
||||
description?: string;
|
||||
schema?: {
|
||||
input?: JSONSchema7;
|
||||
output?: JSONSchema7;
|
||||
};
|
||||
}>;
|
||||
export type ListActionsResponse = Array<Action>;
|
||||
|
||||
// @public
|
||||
export type LogEvent = {
|
||||
|
||||
@@ -44,18 +44,36 @@ export type ScaffolderTask = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The response shape for the `listActions` call to the `scaffolder-backend`
|
||||
* A single action example
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ListActionsResponse = Array<{
|
||||
export type ActionExample = {
|
||||
description: string;
|
||||
example: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The response shape for a single action in the `listActions` call to the `scaffolder-backend`
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type Action = {
|
||||
id: string;
|
||||
description?: string;
|
||||
schema?: {
|
||||
input?: JSONSchema7;
|
||||
output?: JSONSchema7;
|
||||
};
|
||||
}>;
|
||||
examples?: ActionExample[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The response shape for the `listActions` call to the `scaffolder-backend`
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ListActionsResponse = Array<Action>;
|
||||
|
||||
/** @public */
|
||||
export type ScaffolderOutputLink = {
|
||||
|
||||
@@ -13,9 +13,12 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import React, { Fragment } from 'react';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
|
||||
import {
|
||||
ActionExample,
|
||||
scaffolderApiRef,
|
||||
} from '@backstage/plugin-scaffolder-react';
|
||||
import {
|
||||
Typography,
|
||||
Paper,
|
||||
@@ -27,10 +30,15 @@ import {
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Grid,
|
||||
makeStyles,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
} from '@material-ui/core';
|
||||
import { JSONSchema7, JSONSchema7Definition } from 'json-schema';
|
||||
import classNames from 'classnames';
|
||||
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
|
||||
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
@@ -39,6 +47,7 @@ import {
|
||||
Header,
|
||||
Page,
|
||||
ErrorPage,
|
||||
CodeSnippet,
|
||||
MarkdownContent,
|
||||
} from '@backstage/core-components';
|
||||
|
||||
@@ -68,6 +77,34 @@ const useStyles = makeStyles(theme => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const ExamplesTable = (props: { examples: ActionExample[] }) => {
|
||||
return (
|
||||
<Grid container>
|
||||
{props.examples.map((example, index) => {
|
||||
return (
|
||||
<Fragment key={`example-${index}`}>
|
||||
<Grid item lg={3}>
|
||||
<Box padding={4}>
|
||||
<Typography>{example.description}</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item lg={9}>
|
||||
<Box padding={1}>
|
||||
<CodeSnippet
|
||||
text={example.example}
|
||||
showLineNumbers
|
||||
showCopyCodeButton
|
||||
language="yaml"
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export const ActionsPage = () => {
|
||||
const api = useApi(scaffolderApiRef);
|
||||
const classes = useStyles();
|
||||
@@ -181,6 +218,18 @@ export const ActionsPage = () => {
|
||||
{renderTable(action.schema.output)}
|
||||
</Box>
|
||||
)}
|
||||
{action.examples && (
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h5">Examples</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box pb={2}>
|
||||
<ExamplesTable examples={action.examples} />
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export { ScaffolderClient } from './api';
|
||||
|
||||
export {
|
||||
|
||||
@@ -141,6 +141,7 @@ export const adjustEntries = (
|
||||
activeEntry && entry.id === activeEntry?.id
|
||||
? entry.ring.color
|
||||
: color(entry.ring.color).desaturate(0.5).lighten(0.1).string(),
|
||||
active: activeEntry && entry.id === activeEntry?.id ? true : false,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -68,8 +68,16 @@ const useStyles = makeStyles<Theme>(theme => ({
|
||||
userSelect: 'none',
|
||||
fontSize: '11px',
|
||||
},
|
||||
activeEntry: {
|
||||
pointerEvents: 'visiblePainted',
|
||||
userSelect: 'none',
|
||||
fontSize: '11px',
|
||||
background: '#6f6f6f',
|
||||
color: '#FFF',
|
||||
},
|
||||
entryLink: {
|
||||
pointerEvents: 'visiblePainted',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ type RadarLegendLinkProps = {
|
||||
description?: string;
|
||||
title?: string;
|
||||
classes: ClassNameMap<string>;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
export const RadarLegendLink = ({
|
||||
@@ -30,6 +31,7 @@ export const RadarLegendLink = ({
|
||||
description,
|
||||
title,
|
||||
classes,
|
||||
active,
|
||||
}: RadarLegendLinkProps) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
@@ -55,7 +57,9 @@ export const RadarLegendLink = ({
|
||||
tabIndex={0}
|
||||
onKeyPress={toggle}
|
||||
>
|
||||
<span className={classes.entry}>{title}</span>
|
||||
<span className={active ? classes.activeEntry : classes.entry}>
|
||||
{title}
|
||||
</span>
|
||||
</span>
|
||||
{open && (
|
||||
<RadarDescription
|
||||
@@ -71,7 +75,9 @@ export const RadarLegendLink = ({
|
||||
}
|
||||
return (
|
||||
<WithLink url={url} className={classes.entryLink}>
|
||||
<span className={classes.entry}>{title}</span>
|
||||
<span className={active ? classes.activeEntry : classes.entry}>
|
||||
{title}
|
||||
</span>
|
||||
</WithLink>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -57,6 +57,7 @@ export const RadarLegendRing = ({
|
||||
url={entry.url}
|
||||
title={entry.title}
|
||||
description={entry.description}
|
||||
active={entry.active}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
|
||||
+43
-45
@@ -2966,90 +2966,90 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-darwin-arm64@npm:1.3.24":
|
||||
version: 1.3.24
|
||||
resolution: "@swc/core-darwin-arm64@npm:1.3.24"
|
||||
"@swc/core-darwin-arm64@npm:1.3.25":
|
||||
version: 1.3.25
|
||||
resolution: "@swc/core-darwin-arm64@npm:1.3.25"
|
||||
conditions: os=darwin & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-darwin-x64@npm:1.3.24":
|
||||
version: 1.3.24
|
||||
resolution: "@swc/core-darwin-x64@npm:1.3.24"
|
||||
"@swc/core-darwin-x64@npm:1.3.25":
|
||||
version: 1.3.25
|
||||
resolution: "@swc/core-darwin-x64@npm:1.3.25"
|
||||
conditions: os=darwin & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-linux-arm-gnueabihf@npm:1.3.24":
|
||||
version: 1.3.24
|
||||
resolution: "@swc/core-linux-arm-gnueabihf@npm:1.3.24"
|
||||
"@swc/core-linux-arm-gnueabihf@npm:1.3.25":
|
||||
version: 1.3.25
|
||||
resolution: "@swc/core-linux-arm-gnueabihf@npm:1.3.25"
|
||||
conditions: os=linux & cpu=arm
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-linux-arm64-gnu@npm:1.3.24":
|
||||
version: 1.3.24
|
||||
resolution: "@swc/core-linux-arm64-gnu@npm:1.3.24"
|
||||
"@swc/core-linux-arm64-gnu@npm:1.3.25":
|
||||
version: 1.3.25
|
||||
resolution: "@swc/core-linux-arm64-gnu@npm:1.3.25"
|
||||
conditions: os=linux & cpu=arm64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-linux-arm64-musl@npm:1.3.24":
|
||||
version: 1.3.24
|
||||
resolution: "@swc/core-linux-arm64-musl@npm:1.3.24"
|
||||
"@swc/core-linux-arm64-musl@npm:1.3.25":
|
||||
version: 1.3.25
|
||||
resolution: "@swc/core-linux-arm64-musl@npm:1.3.25"
|
||||
conditions: os=linux & cpu=arm64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-linux-x64-gnu@npm:1.3.24":
|
||||
version: 1.3.24
|
||||
resolution: "@swc/core-linux-x64-gnu@npm:1.3.24"
|
||||
"@swc/core-linux-x64-gnu@npm:1.3.25":
|
||||
version: 1.3.25
|
||||
resolution: "@swc/core-linux-x64-gnu@npm:1.3.25"
|
||||
conditions: os=linux & cpu=x64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-linux-x64-musl@npm:1.3.24":
|
||||
version: 1.3.24
|
||||
resolution: "@swc/core-linux-x64-musl@npm:1.3.24"
|
||||
"@swc/core-linux-x64-musl@npm:1.3.25":
|
||||
version: 1.3.25
|
||||
resolution: "@swc/core-linux-x64-musl@npm:1.3.25"
|
||||
conditions: os=linux & cpu=x64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-win32-arm64-msvc@npm:1.3.24":
|
||||
version: 1.3.24
|
||||
resolution: "@swc/core-win32-arm64-msvc@npm:1.3.24"
|
||||
"@swc/core-win32-arm64-msvc@npm:1.3.25":
|
||||
version: 1.3.25
|
||||
resolution: "@swc/core-win32-arm64-msvc@npm:1.3.25"
|
||||
conditions: os=win32 & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-win32-ia32-msvc@npm:1.3.24":
|
||||
version: 1.3.24
|
||||
resolution: "@swc/core-win32-ia32-msvc@npm:1.3.24"
|
||||
"@swc/core-win32-ia32-msvc@npm:1.3.25":
|
||||
version: 1.3.25
|
||||
resolution: "@swc/core-win32-ia32-msvc@npm:1.3.25"
|
||||
conditions: os=win32 & cpu=ia32
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-win32-x64-msvc@npm:1.3.24":
|
||||
version: 1.3.24
|
||||
resolution: "@swc/core-win32-x64-msvc@npm:1.3.24"
|
||||
"@swc/core-win32-x64-msvc@npm:1.3.25":
|
||||
version: 1.3.25
|
||||
resolution: "@swc/core-win32-x64-msvc@npm:1.3.25"
|
||||
conditions: os=win32 & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core@npm:^1.3.9":
|
||||
version: 1.3.24
|
||||
resolution: "@swc/core@npm:1.3.24"
|
||||
version: 1.3.25
|
||||
resolution: "@swc/core@npm:1.3.25"
|
||||
dependencies:
|
||||
"@swc/core-darwin-arm64": 1.3.24
|
||||
"@swc/core-darwin-x64": 1.3.24
|
||||
"@swc/core-linux-arm-gnueabihf": 1.3.24
|
||||
"@swc/core-linux-arm64-gnu": 1.3.24
|
||||
"@swc/core-linux-arm64-musl": 1.3.24
|
||||
"@swc/core-linux-x64-gnu": 1.3.24
|
||||
"@swc/core-linux-x64-musl": 1.3.24
|
||||
"@swc/core-win32-arm64-msvc": 1.3.24
|
||||
"@swc/core-win32-ia32-msvc": 1.3.24
|
||||
"@swc/core-win32-x64-msvc": 1.3.24
|
||||
"@swc/core-darwin-arm64": 1.3.25
|
||||
"@swc/core-darwin-x64": 1.3.25
|
||||
"@swc/core-linux-arm-gnueabihf": 1.3.25
|
||||
"@swc/core-linux-arm64-gnu": 1.3.25
|
||||
"@swc/core-linux-arm64-musl": 1.3.25
|
||||
"@swc/core-linux-x64-gnu": 1.3.25
|
||||
"@swc/core-linux-x64-musl": 1.3.25
|
||||
"@swc/core-win32-arm64-msvc": 1.3.25
|
||||
"@swc/core-win32-ia32-msvc": 1.3.25
|
||||
"@swc/core-win32-x64-msvc": 1.3.25
|
||||
dependenciesMeta:
|
||||
"@swc/core-darwin-arm64":
|
||||
optional: true
|
||||
@@ -3071,9 +3071,7 @@ __metadata:
|
||||
optional: true
|
||||
"@swc/core-win32-x64-msvc":
|
||||
optional: true
|
||||
bin:
|
||||
swcx: run_swcx.js
|
||||
checksum: a27b842be129b83c116f804e63deaa51dbd5d9b77d6260888d549f6408df1dd05aeef20046ceacc9fd7458e6afda6723545249bd77f77086b98bd9bf84738c19
|
||||
checksum: de45a7dd871cc9497ad998d6a320d3c95cb9c74fdcb70590ff1f631e75001820d021bbfd5c463e9172afcb5ee47bffaa8fb893230e1329538c9f7afbd5ed45cf
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
@@ -3381,11 +3381,30 @@ __metadata:
|
||||
"@backstage/backend-plugin-api": "workspace:^"
|
||||
"@backstage/backend-tasks": "workspace:^"
|
||||
"@backstage/cli": "workspace:^"
|
||||
"@backstage/config": "workspace:^"
|
||||
"@backstage/errors": "workspace:^"
|
||||
"@backstage/plugin-permission-node": "workspace:^"
|
||||
"@types/compression": ^1.7.0
|
||||
"@types/cors": ^2.8.6
|
||||
"@types/express": ^4.17.6
|
||||
"@types/fs-extra": ^9.0.3
|
||||
"@types/http-errors": ^2.0.0
|
||||
"@types/morgan": ^1.9.0
|
||||
"@types/node-forge": ^1.3.0
|
||||
"@types/stoppable": ^1.1.0
|
||||
compression: ^1.7.4
|
||||
cors: ^2.8.5
|
||||
express: ^4.17.1
|
||||
express-promise-router: ^4.1.0
|
||||
fs-extra: 10.1.0
|
||||
helmet: ^6.0.0
|
||||
http-errors: ^2.0.0
|
||||
minimatch: ^5.0.0
|
||||
morgan: ^1.10.0
|
||||
node-forge: ^1.3.1
|
||||
selfsigned: ^2.0.0
|
||||
stoppable: ^1.1.0
|
||||
supertest: ^6.1.3
|
||||
winston: ^3.2.1
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
@@ -3394,6 +3413,7 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@backstage/backend-common@workspace:packages/backend-common"
|
||||
dependencies:
|
||||
"@backstage/backend-app-api": "workspace:^"
|
||||
"@backstage/backend-plugin-api": "workspace:^"
|
||||
"@backstage/backend-test-utils": "workspace:^"
|
||||
"@backstage/cli": "workspace:^"
|
||||
@@ -12922,8 +12942,8 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"@rjsf/utils@npm:^5.0.0-beta.14":
|
||||
version: 5.0.0-beta.15
|
||||
resolution: "@rjsf/utils@npm:5.0.0-beta.15"
|
||||
version: 5.0.0-beta.16
|
||||
resolution: "@rjsf/utils@npm:5.0.0-beta.16"
|
||||
dependencies:
|
||||
json-schema-merge-allof: ^0.8.1
|
||||
jsonpointer: ^5.0.1
|
||||
@@ -12932,7 +12952,7 @@ __metadata:
|
||||
react-is: ^18.2.0
|
||||
peerDependencies:
|
||||
react: ^16.14.0 || >=17
|
||||
checksum: e22b1cc13183d6a6a57b900bb80825eff1c4680edadb554adb6737c21237c7862b2a249e5b67c0c6971094b5d21224daf108ad1f400286be924bbc5c29784a00
|
||||
checksum: 7a0f04f20b1b12eff33322bef322ee96e222fc621142fb34906cb18386a2a0dd97224fd97851899644994a1adaab33c6f107076f344b66108219fcb809132703
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -13552,90 +13572,90 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-darwin-arm64@npm:1.3.24":
|
||||
version: 1.3.24
|
||||
resolution: "@swc/core-darwin-arm64@npm:1.3.24"
|
||||
"@swc/core-darwin-arm64@npm:1.3.25":
|
||||
version: 1.3.25
|
||||
resolution: "@swc/core-darwin-arm64@npm:1.3.25"
|
||||
conditions: os=darwin & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-darwin-x64@npm:1.3.24":
|
||||
version: 1.3.24
|
||||
resolution: "@swc/core-darwin-x64@npm:1.3.24"
|
||||
"@swc/core-darwin-x64@npm:1.3.25":
|
||||
version: 1.3.25
|
||||
resolution: "@swc/core-darwin-x64@npm:1.3.25"
|
||||
conditions: os=darwin & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-linux-arm-gnueabihf@npm:1.3.24":
|
||||
version: 1.3.24
|
||||
resolution: "@swc/core-linux-arm-gnueabihf@npm:1.3.24"
|
||||
"@swc/core-linux-arm-gnueabihf@npm:1.3.25":
|
||||
version: 1.3.25
|
||||
resolution: "@swc/core-linux-arm-gnueabihf@npm:1.3.25"
|
||||
conditions: os=linux & cpu=arm
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-linux-arm64-gnu@npm:1.3.24":
|
||||
version: 1.3.24
|
||||
resolution: "@swc/core-linux-arm64-gnu@npm:1.3.24"
|
||||
"@swc/core-linux-arm64-gnu@npm:1.3.25":
|
||||
version: 1.3.25
|
||||
resolution: "@swc/core-linux-arm64-gnu@npm:1.3.25"
|
||||
conditions: os=linux & cpu=arm64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-linux-arm64-musl@npm:1.3.24":
|
||||
version: 1.3.24
|
||||
resolution: "@swc/core-linux-arm64-musl@npm:1.3.24"
|
||||
"@swc/core-linux-arm64-musl@npm:1.3.25":
|
||||
version: 1.3.25
|
||||
resolution: "@swc/core-linux-arm64-musl@npm:1.3.25"
|
||||
conditions: os=linux & cpu=arm64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-linux-x64-gnu@npm:1.3.24":
|
||||
version: 1.3.24
|
||||
resolution: "@swc/core-linux-x64-gnu@npm:1.3.24"
|
||||
"@swc/core-linux-x64-gnu@npm:1.3.25":
|
||||
version: 1.3.25
|
||||
resolution: "@swc/core-linux-x64-gnu@npm:1.3.25"
|
||||
conditions: os=linux & cpu=x64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-linux-x64-musl@npm:1.3.24":
|
||||
version: 1.3.24
|
||||
resolution: "@swc/core-linux-x64-musl@npm:1.3.24"
|
||||
"@swc/core-linux-x64-musl@npm:1.3.25":
|
||||
version: 1.3.25
|
||||
resolution: "@swc/core-linux-x64-musl@npm:1.3.25"
|
||||
conditions: os=linux & cpu=x64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-win32-arm64-msvc@npm:1.3.24":
|
||||
version: 1.3.24
|
||||
resolution: "@swc/core-win32-arm64-msvc@npm:1.3.24"
|
||||
"@swc/core-win32-arm64-msvc@npm:1.3.25":
|
||||
version: 1.3.25
|
||||
resolution: "@swc/core-win32-arm64-msvc@npm:1.3.25"
|
||||
conditions: os=win32 & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-win32-ia32-msvc@npm:1.3.24":
|
||||
version: 1.3.24
|
||||
resolution: "@swc/core-win32-ia32-msvc@npm:1.3.24"
|
||||
"@swc/core-win32-ia32-msvc@npm:1.3.25":
|
||||
version: 1.3.25
|
||||
resolution: "@swc/core-win32-ia32-msvc@npm:1.3.25"
|
||||
conditions: os=win32 & cpu=ia32
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core-win32-x64-msvc@npm:1.3.24":
|
||||
version: 1.3.24
|
||||
resolution: "@swc/core-win32-x64-msvc@npm:1.3.24"
|
||||
"@swc/core-win32-x64-msvc@npm:1.3.25":
|
||||
version: 1.3.25
|
||||
resolution: "@swc/core-win32-x64-msvc@npm:1.3.25"
|
||||
conditions: os=win32 & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@swc/core@npm:^1.3.9":
|
||||
version: 1.3.24
|
||||
resolution: "@swc/core@npm:1.3.24"
|
||||
version: 1.3.25
|
||||
resolution: "@swc/core@npm:1.3.25"
|
||||
dependencies:
|
||||
"@swc/core-darwin-arm64": 1.3.24
|
||||
"@swc/core-darwin-x64": 1.3.24
|
||||
"@swc/core-linux-arm-gnueabihf": 1.3.24
|
||||
"@swc/core-linux-arm64-gnu": 1.3.24
|
||||
"@swc/core-linux-arm64-musl": 1.3.24
|
||||
"@swc/core-linux-x64-gnu": 1.3.24
|
||||
"@swc/core-linux-x64-musl": 1.3.24
|
||||
"@swc/core-win32-arm64-msvc": 1.3.24
|
||||
"@swc/core-win32-ia32-msvc": 1.3.24
|
||||
"@swc/core-win32-x64-msvc": 1.3.24
|
||||
"@swc/core-darwin-arm64": 1.3.25
|
||||
"@swc/core-darwin-x64": 1.3.25
|
||||
"@swc/core-linux-arm-gnueabihf": 1.3.25
|
||||
"@swc/core-linux-arm64-gnu": 1.3.25
|
||||
"@swc/core-linux-arm64-musl": 1.3.25
|
||||
"@swc/core-linux-x64-gnu": 1.3.25
|
||||
"@swc/core-linux-x64-musl": 1.3.25
|
||||
"@swc/core-win32-arm64-msvc": 1.3.25
|
||||
"@swc/core-win32-ia32-msvc": 1.3.25
|
||||
"@swc/core-win32-x64-msvc": 1.3.25
|
||||
dependenciesMeta:
|
||||
"@swc/core-darwin-arm64":
|
||||
optional: true
|
||||
@@ -13657,9 +13677,7 @@ __metadata:
|
||||
optional: true
|
||||
"@swc/core-win32-x64-msvc":
|
||||
optional: true
|
||||
bin:
|
||||
swcx: run_swcx.js
|
||||
checksum: a27b842be129b83c116f804e63deaa51dbd5d9b77d6260888d549f6408df1dd05aeef20046ceacc9fd7458e6afda6723545249bd77f77086b98bd9bf84738c19
|
||||
checksum: de45a7dd871cc9497ad998d6a320d3c95cb9c74fdcb70590ff1f631e75001820d021bbfd5c463e9172afcb5ee47bffaa8fb893230e1329538c9f7afbd5ed45cf
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -26834,9 +26852,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"js-base64@npm:^3.6.0":
|
||||
version: 3.7.3
|
||||
resolution: "js-base64@npm:3.7.3"
|
||||
checksum: ee19bed9ba21693e4583a47773dd701938de63db82e7c324d4c19093046157f0d87905cc20540f194f1d0ec88d6e1bfc4056aea916bcb873ebcf95942d26aa5a
|
||||
version: 3.7.4
|
||||
resolution: "js-base64@npm:3.7.4"
|
||||
checksum: b8f6b10033b57bbb87fa34629f44bc665cb8b7bcba49610a29b296592616e633e9c186afaa02de5a54d60c08aaa36c8c8ad585db095a96fb331fe022f54792a2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user