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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+2
@@ -258,3 +258,5 @@ declare module 'webpack-node-externals' {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@esbuild-kit/cjs-loader' {}
|
||||
|
||||
Reference in New Issue
Block a user