cli: add experimental backend start

Co-authored-by: blam <ben@blam.sh>
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-01-31 16:28:40 +01:00
parent a70806c3b0
commit 90616df9a8
8 changed files with 392 additions and 13 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Added an experimental mode for the `package start` command for backend packages. Enabled by setting `EXPERIMENTAL_BACKEND_START`.
+3
View File
@@ -36,6 +36,7 @@
"@backstage/errors": "workspace:^",
"@backstage/release-manifests": "workspace:^",
"@backstage/types": "workspace:^",
"@esbuild-kit/cjs-loader": "^2.4.1",
"@manypkg/get-packages": "^1.1.3",
"@octokit/request": "^6.0.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.7",
@@ -65,6 +66,7 @@
"chalk": "^4.0.0",
"chokidar": "^3.3.1",
"commander": "^9.1.0",
"cross-spawn": "^7.0.3",
"css-loader": "^6.5.1",
"diff": "^5.0.0",
"esbuild": "^0.17.0",
@@ -136,6 +138,7 @@
"@backstage/dev-utils": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@backstage/theme": "workspace:^",
"@types/cross-spawn": "^6.0.2",
"@types/diff": "^5.0.0",
"@types/express": "^4.17.6",
"@types/fs-extra": "^9.0.1",
+23 -11
View File
@@ -17,6 +17,7 @@
import fs from 'fs-extra';
import { paths } from '../../lib/paths';
import { serveBackend } from '../../lib/bundler';
import { startBackendExperimental } from '../../lib/experimental/startBackendExperimental';
interface StartBackendOptions {
checksEnabled: boolean;
@@ -25,17 +26,28 @@ interface StartBackendOptions {
}
export async function startBackend(options: StartBackendOptions) {
// Cleaning dist/ before we start the dev process helps work around an issue
// where we end up with the entrypoint executing multiple times, causing
// a port bind conflict among other things.
await fs.remove(paths.resolveTarget('dist'));
if (process.env.EXPERIMENTAL_BACKEND_START) {
const waitForExit = await startBackendExperimental({
entry: 'src/index',
checksEnabled: false, // not supported
inspectEnabled: options.inspectEnabled,
inspectBrkEnabled: options.inspectBrkEnabled,
});
const waitForExit = await serveBackend({
entry: 'src/index',
checksEnabled: options.checksEnabled,
inspectEnabled: options.inspectEnabled,
inspectBrkEnabled: options.inspectBrkEnabled,
});
await waitForExit();
} else {
// Cleaning dist/ before we start the dev process helps work around an issue
// where we end up with the entrypoint executing multiple times, causing
// a port bind conflict among other things.
await fs.remove(paths.resolveTarget('dist'));
await waitForExit();
const waitForExit = await serveBackend({
entry: 'src/index',
checksEnabled: options.checksEnabled,
inspectEnabled: options.inspectEnabled,
inspectBrkEnabled: options.inspectBrkEnabled,
});
await waitForExit();
}
}
@@ -0,0 +1,102 @@
/*
* 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 { serializeError } from '@backstage/errors';
import { ChildProcess } from 'child_process';
interface RequestMeta {
generation: number;
}
type MethodHandler<TRequest, TResponse> = (
req: TRequest,
meta: RequestMeta,
) => Promise<TResponse>;
interface Request {
id: number;
method: string;
body: unknown;
type: string;
}
const requestType = '@backstage/cli/channel/request';
const responseType = '@backstage/cli/channel/response';
export class ChannelServer {
#generation = 1;
#methods = new Map<string, MethodHandler<any, any>>();
addChild(child: ChildProcess) {
const generation = this.#generation++;
const sendMessage = child.send?.bind(child);
if (!sendMessage) {
return;
}
const messageListener = (request: Request) => {
if (request.type !== requestType) {
return;
}
const handler = this.#methods.get(request.method);
if (!handler) {
sendMessage({
type: responseType,
id: request.id,
error: {
name: 'NotFoundError',
message: `No handler registered for method ${request.method}`,
},
});
return;
}
Promise.resolve()
.then(() => handler(request.body, { generation }))
.then(response =>
sendMessage({
type: responseType,
id: request.id,
body: response,
}),
)
.catch(error =>
sendMessage({
type: responseType,
id: request.id,
error: serializeError(error),
}),
);
};
child.addListener('message', messageListener as (req: unknown) => void);
child.addListener('exit', () => {
child.removeListener('message', messageListener);
});
}
registerMethod<TRequest, TResponse>(
method: string,
handler: MethodHandler<TRequest, TResponse>,
) {
if (this.#methods.has(method)) {
throw new Error(`A handler is already registered for method ${method}`);
}
this.#methods.set(method, handler);
}
}
@@ -0,0 +1,78 @@
/*
* 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 { ChannelServer } from './ChannelServer';
interface StorageItem {
generation: number;
data: unknown;
}
interface SaveRequest {
key: string;
data: unknown;
}
interface SaveResponse {
saved: boolean;
}
interface LoadRequest {
key: string;
}
interface LoadResponse {
loaded: boolean;
data: unknown;
}
export class ServerDataStore {
static bind(server: ChannelServer): void {
const store = new Map<string, StorageItem>();
server.registerMethod<SaveRequest, SaveResponse>(
'DevDataStore.save',
async (request, { generation }) => {
const { key, data } = request;
if (!key) {
throw new Error('Key is required in DevDataStore.save');
}
const item = store.get(key);
if (!item) {
store.set(key, { generation, data });
return { saved: true };
}
if (item.generation > generation) {
return { saved: false };
}
store.set(key, { generation, data });
return { saved: true };
},
);
server.registerMethod<LoadRequest, LoadResponse>(
'DevDataStore.load',
async request => {
const item = store.get(request.key);
return { loaded: Boolean(item), data: item?.data };
},
);
}
}
@@ -0,0 +1,138 @@
/*
* 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 { BackendServeOptions } from '../bundler/types';
import type { ChildProcess } from 'child_process';
import { fileURLToPath } from 'url';
import { isAbsolute as isAbsolutePath } from 'path';
import { FSWatcher, watch } from 'chokidar';
import { ChannelServer } from './ChannelServer';
import { ServerDataStore } from './ServerDataStore';
import debounce from 'lodash/debounce';
import spawn from 'cross-spawn';
import { paths } from '../paths';
const loaderArgs = ['--require', require.resolve('@esbuild-kit/cjs-loader')];
export async function startBackendExperimental(options: BackendServeOptions) {
const envEnv = process.env as { NODE_ENV: string };
if (!envEnv.NODE_ENV) {
envEnv.NODE_ENV = 'development';
}
// Set up the parent IPC server and bind the available services
const server = new ChannelServer();
ServerDataStore.bind(server);
let exiting = false;
let child: ChildProcess | undefined;
let watcher: FSWatcher | undefined = undefined;
let shutdownPromise: Promise<void> | undefined = undefined;
const restart = debounce(async () => {
// If a re-trigger happens during an existing shutdown, we just ignore it
if (shutdownPromise) {
return;
}
if (child && !child.killed && child.exitCode === null) {
// We always wait for the existing process to exit, to make sure we don't get IPC conflicts
shutdownPromise = new Promise(resolve => child!.once('exit', resolve));
child.kill();
await shutdownPromise;
shutdownPromise = undefined;
}
// We've received a shutdown signal
if (exiting) {
return;
}
const optionArgs = new Array<string>();
if (options.inspectEnabled) {
optionArgs.push('--inspect');
} else if (options.inspectBrkEnabled) {
optionArgs.push('--inspect-brk');
}
const userArgs = process.argv
.slice(['node', 'backstage-cli', 'package', 'start'].length)
.filter(arg => !optionArgs.includes(arg));
child = spawn(
process.execPath,
[...loaderArgs, ...optionArgs, options.entry, ...userArgs],
{
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
env: {
...process.env,
BACKSTAGE_CLI_CHANNEL: '1',
ESBK_TSCONFIG_PATH: paths.resolveTargetRoot('tsconfig.json'),
},
serialization: 'advanced',
},
);
server.addChild(child);
// This captures messages sent by @esbuild-kit/cjs-loader
child.on('message', (data: { type?: string } | null) => {
if (typeof data === 'object' && data?.type === 'dependency') {
let path = (data as { path: string }).path;
if (path.startsWith('file:')) {
path = fileURLToPath(path);
}
if (isAbsolutePath(path)) {
watcher?.add(path);
}
}
});
}, 100);
restart();
watcher = watch([paths.targetDir], {
cwd: process.cwd(),
ignored: ['**/.*/**', '**/node_modules/**'],
ignoreInitial: true,
ignorePermissionErrors: true,
}).on('all', restart);
// Trigger restart on hitting enter in the terminal
process.stdin.on('data', restart);
const exitPromise = new Promise<void>(resolveExitPromise => {
async function handleSignal(signal: NodeJS.Signals) {
exiting = true;
// Forward signals to child and wait for it to exit if still running
if (child && child.exitCode === null) {
await new Promise(resolve => {
child!.on('close', resolve);
child!.kill(signal);
});
}
resolveExitPromise();
}
process.once('SIGINT', handleSignal);
process.once('SIGTERM', handleSignal);
});
return () => exitPromise;
}
+2
View File
@@ -258,3 +258,5 @@ declare module 'webpack-node-externals' {
}
}
}
declare module '@esbuild-kit/cjs-loader' {}
+41 -2
View File
@@ -3672,6 +3672,7 @@ __metadata:
"@backstage/test-utils": "workspace:^"
"@backstage/theme": "workspace:^"
"@backstage/types": "workspace:^"
"@esbuild-kit/cjs-loader": ^2.4.1
"@manypkg/get-packages": ^1.1.3
"@octokit/request": ^6.0.0
"@pmmmwh/react-refresh-webpack-plugin": ^0.5.7
@@ -3690,6 +3691,7 @@ __metadata:
"@swc/core": ^1.3.9
"@swc/helpers": ^0.4.7
"@swc/jest": ^0.2.22
"@types/cross-spawn": ^6.0.2
"@types/diff": ^5.0.0
"@types/express": ^4.17.6
"@types/fs-extra": ^9.0.1
@@ -3717,6 +3719,7 @@ __metadata:
chalk: ^4.0.0
chokidar: ^3.3.1
commander: ^9.1.0
cross-spawn: ^7.0.3
css-loader: ^6.5.1
del: ^6.0.0
diff: ^5.0.0
@@ -9196,6 +9199,26 @@ __metadata:
languageName: node
linkType: hard
"@esbuild-kit/cjs-loader@npm:^2.4.1":
version: 2.4.1
resolution: "@esbuild-kit/cjs-loader@npm:2.4.1"
dependencies:
"@esbuild-kit/core-utils": ^3.0.0
get-tsconfig: ^4.2.0
checksum: a516065907be0ead76ac2199ccb08ff92659ba5e2edb4bb8772b6a63afe4faed7eb45a3b4d87266a68c7c135c3dba971cd087bc6f16c382356e835c7dd3440f5
languageName: node
linkType: hard
"@esbuild-kit/core-utils@npm:^3.0.0":
version: 3.0.0
resolution: "@esbuild-kit/core-utils@npm:3.0.0"
dependencies:
esbuild: ~0.15.10
source-map-support: ^0.5.21
checksum: 0e89ec718e2211bf95c48a8085aaef88e8e416f42abd1c62d488d5458eecd3fbc144179a0c5570ad36fa7e2d3bbc411f8d3fb28802c37ced2154dc2c6ded9dfe
languageName: node
linkType: hard
"@esbuild/android-arm64@npm:0.17.5":
version: 0.17.5
resolution: "@esbuild/android-arm64@npm:0.17.5"
@@ -13912,6 +13935,15 @@ __metadata:
languageName: node
linkType: hard
"@types/cross-spawn@npm:^6.0.2":
version: 6.0.2
resolution: "@types/cross-spawn@npm:6.0.2"
dependencies:
"@types/node": "*"
checksum: fa9edd32178878cab3ea8d6d0260639e0fe4860ddb3887b8de53d6e8036e154fc5f313c653f690975aa25025aea8beb83fb0870b931bf8d9202c3ac530a24c9d
languageName: node
linkType: hard
"@types/d3-color@npm:*":
version: 3.0.2
resolution: "@types/d3-color@npm:3.0.2"
@@ -21335,7 +21367,7 @@ __metadata:
languageName: node
linkType: hard
"esbuild@npm:^0.15.6":
"esbuild@npm:^0.15.6, esbuild@npm:~0.15.10":
version: 0.15.18
resolution: "esbuild@npm:0.15.18"
dependencies:
@@ -23493,6 +23525,13 @@ __metadata:
languageName: node
linkType: hard
"get-tsconfig@npm:^4.2.0":
version: 4.3.0
resolution: "get-tsconfig@npm:4.3.0"
checksum: 2597aab99aa3a24db209e192a3e5874ac47fc5abc71703ee26346e0c5816cb346ca09fc813c739db5862d3a2905d89aeca1b0cbc46c2b272398d672309aaf414
languageName: node
linkType: hard
"getopts@npm:2.3.0":
version: 2.3.0
resolution: "getopts@npm:2.3.0"
@@ -35146,7 +35185,7 @@ __metadata:
languageName: node
linkType: hard
"source-map-support@npm:^0.5.16, source-map-support@npm:~0.5.20":
"source-map-support@npm:^0.5.16, source-map-support@npm:^0.5.21, source-map-support@npm:~0.5.20":
version: 0.5.21
resolution: "source-map-support@npm:0.5.21"
dependencies: