Merge pull request #1986 from spotify/rugvip/bapp
Add app-backend plugin for serving app content
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
# App backend plugin
|
||||
|
||||
This backend plugin can be installed to serve static content of a Backstage app.
|
||||
|
||||
## Installation
|
||||
|
||||
Add both this package and your local frontend app package as dependencies to your backend, for example
|
||||
|
||||
```bash
|
||||
yarn add @backstage/plugin-app-backend example-app
|
||||
```
|
||||
|
||||
By adding the app package as a dependency we ensure that it is built as part of the backend, and that it can be resolved at runtime.
|
||||
|
||||
Now add the plugin router to your app, creating it for example like this:
|
||||
|
||||
```ts
|
||||
const router = await createRouter({
|
||||
logger,
|
||||
appPackageName: 'example-app',
|
||||
});
|
||||
```
|
||||
|
||||
And registering it like this:
|
||||
|
||||
```ts
|
||||
createServiceBuilder(module)
|
||||
...
|
||||
.addRouter('', router);
|
||||
```
|
||||
|
||||
Be sure to register the app router last, as it serves content for HTML5-mode navigation, i.e. falling back to serving `index.html` for any route that can't be found.
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@backstage/plugin-app-backend",
|
||||
"version": "0.1.1-alpha.20",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "backstage-cli backend:dev",
|
||||
"build": "backstage-cli backend:build",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
"prepack": "backstage-cli prepack",
|
||||
"postpack": "backstage-cli postpack",
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.1.1-alpha.20",
|
||||
"@backstage/config-loader": "^0.1.1-alpha.20",
|
||||
"@types/express": "^4.17.6",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^3.0.3",
|
||||
"fs-extra": "^9.0.0",
|
||||
"winston": "^3.2.1",
|
||||
"yn": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.20",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"msw": "^0.19.5",
|
||||
"supertest": "^4.0.2"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"static"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 * from './service/router';
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { injectEnvConfig } from './config';
|
||||
|
||||
jest.mock('fs-extra');
|
||||
|
||||
const fsMock = fs as jest.Mocked<typeof fs>;
|
||||
const readFileMock = (fsMock.readFile as unknown) as jest.MockedFunction<
|
||||
(name: string) => Promise<string>
|
||||
>;
|
||||
|
||||
const MOCK_DIR = 'mock-dir';
|
||||
|
||||
const baseOptions = {
|
||||
env: {},
|
||||
staticDir: MOCK_DIR,
|
||||
logger: getVoidLogger(),
|
||||
};
|
||||
|
||||
describe('injectEnvConfig', () => {
|
||||
beforeEach(() => {
|
||||
fsMock.readdir.mockResolvedValue(['main.js']);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should not inject without config', async () => {
|
||||
await injectEnvConfig(baseOptions);
|
||||
expect(fsMock.readdir).toHaveBeenCalledTimes(0);
|
||||
expect(fsMock.readFile).toHaveBeenCalledTimes(0);
|
||||
expect(fsMock.writeFile).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should find the correct file to inject', async () => {
|
||||
fsMock.readdir.mockResolvedValue([
|
||||
'before.js',
|
||||
'not-js.txt',
|
||||
'main.js',
|
||||
'after.js',
|
||||
]);
|
||||
readFileMock.mockImplementation(async (file: string) => {
|
||||
if (file.endsWith('main.js')) {
|
||||
return '"__APP_INJECTED_RUNTIME_CONFIG__"';
|
||||
}
|
||||
return 'NO_PLACEHOLDER_HERE';
|
||||
});
|
||||
|
||||
await injectEnvConfig({ ...baseOptions, env: { APP_CONFIG_x: '0' } });
|
||||
expect(fsMock.readFile).toHaveBeenCalledTimes(2);
|
||||
expect(fsMock.readFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
resolvePath(MOCK_DIR, 'before.js'),
|
||||
'utf8',
|
||||
);
|
||||
expect(fsMock.readFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
resolvePath(MOCK_DIR, 'main.js'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
expect(fsMock.writeFile).toHaveBeenCalledTimes(1);
|
||||
expect(fsMock.writeFile).toHaveBeenCalledWith(
|
||||
resolvePath(MOCK_DIR, 'main.js'),
|
||||
'/*__APP_INJECTED_CONFIG_MARKER__*/"{\\"x\\":0}"/*__INJECTED_END__*/',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// eslint-disable-next-line no-eval
|
||||
expect(JSON.parse(eval(fsMock.writeFile.mock.calls[0][1]))).toEqual({
|
||||
x: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should re-inject config', async () => {
|
||||
fsMock.readdir.mockResolvedValue(['main.js']);
|
||||
readFileMock.mockResolvedValue(
|
||||
'JSON.parse("__APP_INJECTED_RUNTIME_CONFIG__")',
|
||||
);
|
||||
|
||||
await injectEnvConfig({
|
||||
...baseOptions,
|
||||
env: {
|
||||
APP_CONFIG_x: '0',
|
||||
},
|
||||
});
|
||||
|
||||
expect(fsMock.writeFile).toHaveBeenCalledTimes(1);
|
||||
expect(fsMock.writeFile).toHaveBeenCalledWith(
|
||||
resolvePath(MOCK_DIR, 'main.js'),
|
||||
'JSON.parse(/*__APP_INJECTED_CONFIG_MARKER__*/"{\\"x\\":0}"/*__INJECTED_END__*/)',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// eslint-disable-next-line no-eval
|
||||
expect(eval(fsMock.writeFile.mock.calls[0][1])).toEqual({ x: 0 });
|
||||
|
||||
readFileMock.mockResolvedValue(fsMock.writeFile.mock.calls[0][1]);
|
||||
|
||||
await injectEnvConfig({
|
||||
...baseOptions,
|
||||
env: {
|
||||
APP_CONFIG_x: '1',
|
||||
APP_CONFIG_y: '2',
|
||||
},
|
||||
});
|
||||
|
||||
expect(fsMock.writeFile).toHaveBeenCalledTimes(2);
|
||||
expect(fsMock.writeFile).toHaveBeenLastCalledWith(
|
||||
resolvePath(MOCK_DIR, 'main.js'),
|
||||
'JSON.parse(/*__APP_INJECTED_CONFIG_MARKER__*/"{\\"x\\":1,\\"y\\":2}"/*__INJECTED_END__*/)',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// eslint-disable-next-line no-eval
|
||||
expect(eval(fsMock.writeFile.mock.calls[1][1])).toEqual({ x: 1, y: 2 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { readEnvConfig } from '@backstage/config-loader';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
type Options = {
|
||||
// Environment to read config from
|
||||
env: { [name: string]: string | undefined };
|
||||
// Directory of the static JS files to search for file to inject
|
||||
staticDir: string;
|
||||
logger: Logger;
|
||||
};
|
||||
|
||||
/**
|
||||
* Injects config from APP_CONFIG_ env vars, replacing existing
|
||||
* injected config if it has already been injected.
|
||||
*/
|
||||
export async function injectEnvConfig(options: Options) {
|
||||
const { env, staticDir, logger } = options;
|
||||
|
||||
const envConfig = readEnvConfig(env);
|
||||
if (envConfig.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await fs.readdir(staticDir);
|
||||
const jsFiles = files.filter(file => file.endsWith('.js'));
|
||||
|
||||
const [{ data }] = envConfig;
|
||||
const escapedData = JSON.stringify(data).replace(/("|'|\\)/g, '\\$1');
|
||||
const injected = `/*__APP_INJECTED_CONFIG_MARKER__*/"${escapedData}"/*__INJECTED_END__*/`;
|
||||
|
||||
for (const jsFile of jsFiles) {
|
||||
const path = resolvePath(staticDir, jsFile);
|
||||
|
||||
const content = await fs.readFile(path, 'utf8');
|
||||
if (content.includes('__APP_INJECTED_RUNTIME_CONFIG__')) {
|
||||
logger.info(`Injecting env config into ${jsFile}`);
|
||||
|
||||
const newContent = content.replace(
|
||||
'"__APP_INJECTED_RUNTIME_CONFIG__"',
|
||||
injected,
|
||||
);
|
||||
await fs.writeFile(path, newContent, 'utf8');
|
||||
return;
|
||||
} else if (content.includes('__APP_INJECTED_CONFIG_MARKER__')) {
|
||||
logger.info(`Replacing injected env config in ${jsFile}`);
|
||||
|
||||
const newContent = content.replace(
|
||||
/\/\*__APP_INJECTED_CONFIG_MARKER__\*\/.*\/\*__INJECTED_END__\*\//,
|
||||
injected,
|
||||
);
|
||||
await fs.writeFile(path, newContent, 'utf8');
|
||||
return;
|
||||
}
|
||||
}
|
||||
logger.info('Env config not injected');
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 { getRootLogger } from '@backstage/backend-common';
|
||||
import yn from 'yn';
|
||||
import { startStandaloneServer } from './service/standaloneServer';
|
||||
|
||||
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000;
|
||||
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
|
||||
const logger = getRootLogger();
|
||||
|
||||
startStandaloneServer({ port, enableCors, logger }).catch(err => {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('CTRL+C pressed; exiting.');
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
!dist
|
||||
@@ -0,0 +1 @@
|
||||
this is index.html
|
||||
@@ -0,0 +1 @@
|
||||
this is other.html
|
||||
@@ -0,0 +1 @@
|
||||
this is main.txt
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 { resolve as resolvePath } from 'path';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import request from 'supertest';
|
||||
|
||||
import { createRouter } from './router';
|
||||
|
||||
jest.mock('../lib/config', () => ({ injectEnvConfig: jest.fn() }));
|
||||
|
||||
global.__non_webpack_require__ = {
|
||||
resolve: () => resolvePath(__dirname, '__fixtures__/app-dir/package.json'),
|
||||
};
|
||||
|
||||
describe('createRouter', () => {
|
||||
let app: express.Express;
|
||||
|
||||
beforeAll(async () => {
|
||||
const router = await createRouter({
|
||||
logger: getVoidLogger(),
|
||||
appPackageName: 'example-app',
|
||||
});
|
||||
app = express().use(router);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('returns index.html', async () => {
|
||||
const response = await request(app).get('/index.html');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toBe('this is index.html\n');
|
||||
});
|
||||
|
||||
it('returns other.html', async () => {
|
||||
const response = await request(app).get('/other.html');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toBe('this is other.html\n');
|
||||
});
|
||||
|
||||
it('returns index.html if missing', async () => {
|
||||
const response = await request(app).get('/missing.html');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toBe('this is index.html\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRouter with static fallback handler', () => {
|
||||
it('uses static fallback handler', async () => {
|
||||
const staticFallbackHandler = Router();
|
||||
|
||||
staticFallbackHandler.get('/test.txt', (_req, res) => {
|
||||
res.end('this is test.txt');
|
||||
});
|
||||
|
||||
const router = await createRouter({
|
||||
logger: getVoidLogger(),
|
||||
appPackageName: 'example-app',
|
||||
staticFallbackHandler,
|
||||
});
|
||||
|
||||
const app = express().use(router);
|
||||
|
||||
const response1 = await request(app).get('/static/main.txt');
|
||||
expect(response1.status).toBe(200);
|
||||
expect(response1.text).toBe('this is main.txt\n');
|
||||
|
||||
const response2 = await request(app).get('/static/test.txt');
|
||||
expect(response2.status).toBe(200);
|
||||
expect(response2.text).toBe('this is test.txt');
|
||||
|
||||
const response3 = await request(app).get('/static/missing.txt');
|
||||
expect(response3.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 { resolve as resolvePath, dirname } from 'path';
|
||||
import { notFoundHandler } from '@backstage/backend-common';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
import { injectEnvConfig } from '../lib/config';
|
||||
|
||||
export interface RouterOptions {
|
||||
logger: Logger;
|
||||
appPackageName?: string;
|
||||
staticFallbackHandler?: express.Handler;
|
||||
}
|
||||
|
||||
export async function createRouter(
|
||||
options: RouterOptions,
|
||||
): Promise<express.Router> {
|
||||
const appDistDir = resolvePath(
|
||||
dirname(
|
||||
__non_webpack_require__.resolve(`${options.appPackageName}/package.json`),
|
||||
),
|
||||
'dist',
|
||||
);
|
||||
options.logger.info(`Serving static app content from ${appDistDir}`);
|
||||
|
||||
await injectEnvConfig({
|
||||
env: process.env,
|
||||
logger: options.logger,
|
||||
staticDir: resolvePath(appDistDir, 'static'),
|
||||
});
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Use a separate router for static content so that a fallback can be provided by backend
|
||||
const staticRouter = Router();
|
||||
staticRouter.use(express.static(resolvePath(appDistDir, 'static')));
|
||||
if (options.staticFallbackHandler) {
|
||||
staticRouter.use(options.staticFallbackHandler);
|
||||
}
|
||||
staticRouter.use(notFoundHandler());
|
||||
|
||||
router.use('/static', staticRouter);
|
||||
router.use(express.static(appDistDir));
|
||||
router.get('/*', (_req, res) => {
|
||||
res.sendFile(resolvePath(appDistDir, 'index.html'));
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 { createServiceBuilder } from '@backstage/backend-common';
|
||||
import { Server } from 'http';
|
||||
import { Logger } from 'winston';
|
||||
import { createRouter } from './router';
|
||||
|
||||
export interface ServerOptions {
|
||||
port: number;
|
||||
enableCors: boolean;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export async function startStandaloneServer(
|
||||
options: ServerOptions,
|
||||
): Promise<Server> {
|
||||
const logger = options.logger.child({ service: 'app-backend' });
|
||||
logger.debug('Starting application server...');
|
||||
const router = await createRouter({
|
||||
logger,
|
||||
appPackageName: 'example-app',
|
||||
});
|
||||
|
||||
const service = createServiceBuilder(module).addRouter('', router);
|
||||
|
||||
return await service.start().catch(err => {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.hot?.accept();
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 {};
|
||||
Reference in New Issue
Block a user