From b8858e122026c761bbaa95a128f439557e5b14b3 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 15 Aug 2020 17:25:38 +0200 Subject: [PATCH 1/8] cli: add __non_webpack_require__ to backend eslint globals --- packages/cli/config/eslint.backend.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/cli/config/eslint.backend.js b/packages/cli/config/eslint.backend.js index 8389f5c508..9b9efc7649 100644 --- a/packages/cli/config/eslint.backend.js +++ b/packages/cli/config/eslint.backend.js @@ -28,6 +28,9 @@ module.exports = { env: { jest: true, }, + globals: { + __non_webpack_require__: 'readonly', + }, parserOptions: { ecmaVersion: 2018, sourceType: 'module', From 9ae483312ee97f14f757fca1a91928ccd4ef2b7d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 15 Aug 2020 17:53:34 +0200 Subject: [PATCH 2/8] plugins: add initial app-backend --- plugins/app-backend/.eslintrc.js | 3 + plugins/app-backend/README.md | 3 + plugins/app-backend/package.json | 41 ++++++++ plugins/app-backend/scripts/mock-data.sh | 2 + plugins/app-backend/src/index.ts | 17 ++++ plugins/app-backend/src/run.ts | 33 +++++++ .../service/__fixtures__/app-dir/.gitignore | 1 + .../__fixtures__/app-dir/dist/index.html | 1 + .../__fixtures__/app-dir/dist/other.html | 1 + .../__fixtures__/app-dir/dist/static/main.txt | 1 + .../app-backend/src/service/router.test.ts | 93 +++++++++++++++++++ plugins/app-backend/src/service/router.ts | 57 ++++++++++++ .../src/service/standaloneServer.ts | 46 +++++++++ plugins/app-backend/src/setupTests.ts | 17 ++++ 14 files changed, 316 insertions(+) create mode 100644 plugins/app-backend/.eslintrc.js create mode 100644 plugins/app-backend/README.md create mode 100644 plugins/app-backend/package.json create mode 100755 plugins/app-backend/scripts/mock-data.sh create mode 100644 plugins/app-backend/src/index.ts create mode 100644 plugins/app-backend/src/run.ts create mode 100644 plugins/app-backend/src/service/__fixtures__/app-dir/.gitignore create mode 100644 plugins/app-backend/src/service/__fixtures__/app-dir/dist/index.html create mode 100644 plugins/app-backend/src/service/__fixtures__/app-dir/dist/other.html create mode 100644 plugins/app-backend/src/service/__fixtures__/app-dir/dist/static/main.txt create mode 100644 plugins/app-backend/src/service/router.test.ts create mode 100644 plugins/app-backend/src/service/router.ts create mode 100644 plugins/app-backend/src/service/standaloneServer.ts create mode 100644 plugins/app-backend/src/setupTests.ts diff --git a/plugins/app-backend/.eslintrc.js b/plugins/app-backend/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/plugins/app-backend/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/plugins/app-backend/README.md b/plugins/app-backend/README.md new file mode 100644 index 0000000000..28480b81a1 --- /dev/null +++ b/plugins/app-backend/README.md @@ -0,0 +1,3 @@ +# App backend plugin + +This backend plugin can be installed to serve static content of a Backstage app. diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json new file mode 100644 index 0000000000..a26667ef7c --- /dev/null +++ b/plugins/app-backend/package.json @@ -0,0 +1,41 @@ +{ + "name": "@backstage/plugin-app-backend", + "version": "0.1.1-alpha.18", + "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", + "mock-data": "./scripts/mock-data.sh" + }, + "dependencies": { + "@backstage/backend-common": "^0.1.1-alpha.18", + "@types/express": "^4.17.6", + "express": "^4.17.1", + "express-promise-router": "^3.0.3", + "winston": "^3.2.1", + "yn": "^4.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.1.1-alpha.18", + "@types/supertest": "^2.0.8", + "msw": "^0.19.5", + "supertest": "^4.0.2" + }, + "files": [ + "dist", + "static" + ] +} diff --git a/plugins/app-backend/scripts/mock-data.sh b/plugins/app-backend/scripts/mock-data.sh new file mode 100755 index 0000000000..ff30921715 --- /dev/null +++ b/plugins/app-backend/scripts/mock-data.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +echo "use this script to load your service with some mock data if needed!" diff --git a/plugins/app-backend/src/index.ts b/plugins/app-backend/src/index.ts new file mode 100644 index 0000000000..7612c392a2 --- /dev/null +++ b/plugins/app-backend/src/index.ts @@ -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'; diff --git a/plugins/app-backend/src/run.ts b/plugins/app-backend/src/run.ts new file mode 100644 index 0000000000..b96989e4b8 --- /dev/null +++ b/plugins/app-backend/src/run.ts @@ -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); +}); diff --git a/plugins/app-backend/src/service/__fixtures__/app-dir/.gitignore b/plugins/app-backend/src/service/__fixtures__/app-dir/.gitignore new file mode 100644 index 0000000000..cbdb9611d1 --- /dev/null +++ b/plugins/app-backend/src/service/__fixtures__/app-dir/.gitignore @@ -0,0 +1 @@ +!dist diff --git a/plugins/app-backend/src/service/__fixtures__/app-dir/dist/index.html b/plugins/app-backend/src/service/__fixtures__/app-dir/dist/index.html new file mode 100644 index 0000000000..2085b25414 --- /dev/null +++ b/plugins/app-backend/src/service/__fixtures__/app-dir/dist/index.html @@ -0,0 +1 @@ +this is index.html diff --git a/plugins/app-backend/src/service/__fixtures__/app-dir/dist/other.html b/plugins/app-backend/src/service/__fixtures__/app-dir/dist/other.html new file mode 100644 index 0000000000..a4ea9022ac --- /dev/null +++ b/plugins/app-backend/src/service/__fixtures__/app-dir/dist/other.html @@ -0,0 +1 @@ +this is other.html diff --git a/plugins/app-backend/src/service/__fixtures__/app-dir/dist/static/main.txt b/plugins/app-backend/src/service/__fixtures__/app-dir/dist/static/main.txt new file mode 100644 index 0000000000..ec835599f6 --- /dev/null +++ b/plugins/app-backend/src/service/__fixtures__/app-dir/dist/static/main.txt @@ -0,0 +1 @@ +this is main.txt diff --git a/plugins/app-backend/src/service/router.test.ts b/plugins/app-backend/src/service/router.test.ts new file mode 100644 index 0000000000..8910129394 --- /dev/null +++ b/plugins/app-backend/src/service/router.test.ts @@ -0,0 +1,93 @@ +/* + * 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'; + +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); + }); +}); diff --git a/plugins/app-backend/src/service/router.ts b/plugins/app-backend/src/service/router.ts new file mode 100644 index 0000000000..efb6d4718e --- /dev/null +++ b/plugins/app-backend/src/service/router.ts @@ -0,0 +1,57 @@ +/* + * 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'; + +export interface RouterOptions { + logger: Logger; + appPackageName?: string; + staticFallbackHandler?: express.Handler; +} + +export async function createRouter( + options: RouterOptions, +): Promise { + const appDistDir = resolvePath( + dirname( + __non_webpack_require__.resolve(`${options.appPackageName}/package.json`), + ), + 'dist', + ); + options.logger.info(`Serving static app content from ${appDistDir}`); + + 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; +} diff --git a/plugins/app-backend/src/service/standaloneServer.ts b/plugins/app-backend/src/service/standaloneServer.ts new file mode 100644 index 0000000000..8abf3b81f2 --- /dev/null +++ b/plugins/app-backend/src/service/standaloneServer.ts @@ -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 { + 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(); diff --git a/plugins/app-backend/src/setupTests.ts b/plugins/app-backend/src/setupTests.ts new file mode 100644 index 0000000000..ba33cf996b --- /dev/null +++ b/plugins/app-backend/src/setupTests.ts @@ -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 c13de0b115907fff19753f2c439de136b9c16f46 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 16 Aug 2020 09:10:09 +0200 Subject: [PATCH 3/8] config-loader: rename readEnv to readEnvConfig and export from package --- packages/config-loader/src/index.ts | 1 + packages/config-loader/src/lib/env.test.ts | 42 +++++++++++----------- packages/config-loader/src/lib/env.ts | 2 +- packages/config-loader/src/lib/index.ts | 2 +- packages/config-loader/src/loader.ts | 4 +-- 5 files changed, 26 insertions(+), 25 deletions(-) diff --git a/packages/config-loader/src/index.ts b/packages/config-loader/src/index.ts index 6738611a17..02db134fe5 100644 --- a/packages/config-loader/src/index.ts +++ b/packages/config-loader/src/index.ts @@ -14,5 +14,6 @@ * limitations under the License. */ +export { readEnvConfig } from './lib'; export { loadConfig } from './loader'; export type { LoadConfigOptions } from './loader'; diff --git a/packages/config-loader/src/lib/env.test.ts b/packages/config-loader/src/lib/env.test.ts index 93bd962596..6b1e49365a 100644 --- a/packages/config-loader/src/lib/env.test.ts +++ b/packages/config-loader/src/lib/env.test.ts @@ -14,16 +14,16 @@ * limitations under the License. */ -import { readEnv } from './env'; +import { readEnvConfig } from './env'; -describe('readEnv', () => { +describe('readEnvConfig', () => { it('should return empty config for empty env', () => { - expect(readEnv({})).toEqual([]); + expect(readEnvConfig({})).toEqual([]); }); it('should return empty config for no matching keys', () => { expect( - readEnv({ + readEnvConfig({ NODE_ENV: 'production', NOPE_ENV: 'development', APP_CONFIG: 'foo', @@ -34,7 +34,7 @@ describe('readEnv', () => { it('should create config from env', () => { expect( - readEnv({ + readEnvConfig({ NODE_ENV: 'production', APP_CONFIG_foo: '"bar"', APP_CONFIG_numbers_a: '1', @@ -57,22 +57,22 @@ describe('readEnv', () => { }); it('should accept string values', () => { - expect(readEnv({ APP_CONFIG_foo: '"abc"', APP_CONFIG_bar: 'xyz' })).toEqual( - [ - { - data: { - foo: 'abc', - bar: 'xyz', - }, - context: 'env', + expect( + readEnvConfig({ APP_CONFIG_foo: '"abc"', APP_CONFIG_bar: 'xyz' }), + ).toEqual([ + { + data: { + foo: 'abc', + bar: 'xyz', }, - ], - ); + context: 'env', + }, + ]); }); it('should accept complex objects', () => { expect( - readEnv({ + readEnvConfig({ APP_CONFIG_foo: '{ "a": 123, "b": "123", "c": [] }', APP_CONFIG_bar: '[123, "abc", {}]', }), @@ -95,7 +95,7 @@ describe('readEnv', () => { ['APP_CONFIG_fo o'], ['APP_CONFIG_foo_(foo)_foo'], ])('should reject invalid key %p', key => { - expect(() => readEnv({ [key]: '0' })).toThrow( + expect(() => readEnvConfig({ [key]: '0' })).toThrow( `Invalid env config key '${key.replace('APP_CONFIG_', '')}'`, ); }); @@ -103,7 +103,7 @@ describe('readEnv', () => { it.each([['hello'], ['"hello'], ['{'], ['}']])( 'should fallback to string when invalid json value %p', value => { - expect(readEnv({ APP_CONFIG_foo: value })).toEqual([ + expect(readEnvConfig({ APP_CONFIG_foo: value })).toEqual([ { data: { foo: value, @@ -116,7 +116,7 @@ describe('readEnv', () => { it('should not allow null as a value', () => { expect(() => - readEnv({ + readEnvConfig({ APP_CONFIG_foo: 'null', }), ).toThrow( @@ -126,7 +126,7 @@ describe('readEnv', () => { it('should not allow duplicate values', () => { expect(() => - readEnv({ + readEnvConfig({ APP_CONFIG_foo_bar: '1', APP_CONFIG_foo_bar_baz: '2', }), @@ -137,7 +137,7 @@ describe('readEnv', () => { it('should not allow mixing of objects and other values', () => { expect(() => - readEnv({ + readEnvConfig({ APP_CONFIG_nested_foo: '1', APP_CONFIG_nested: '2', }), diff --git a/packages/config-loader/src/lib/env.ts b/packages/config-loader/src/lib/env.ts index 53c6d6c63b..84d39263e3 100644 --- a/packages/config-loader/src/lib/env.ts +++ b/packages/config-loader/src/lib/env.ts @@ -39,7 +39,7 @@ const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i; * * APP_CONFIG_app_title='"My Title"' */ -export function readEnv(env: { +export function readEnvConfig(env: { [name: string]: string | undefined; }): AppConfig[] { let data: JsonObject | undefined = undefined; diff --git a/packages/config-loader/src/lib/index.ts b/packages/config-loader/src/lib/index.ts index 226932784b..40252b9cec 100644 --- a/packages/config-loader/src/lib/index.ts +++ b/packages/config-loader/src/lib/index.ts @@ -16,5 +16,5 @@ export { resolveStaticConfig } from './resolver'; export { readConfigFile } from './reader'; -export { readEnv } from './env'; +export { readEnvConfig } from './env'; export { readSecret } from './secrets'; diff --git a/packages/config-loader/src/loader.ts b/packages/config-loader/src/loader.ts index 679ab71e45..cf5edd3ce3 100644 --- a/packages/config-loader/src/loader.ts +++ b/packages/config-loader/src/loader.ts @@ -20,7 +20,7 @@ import { AppConfig, JsonObject } from '@backstage/config'; import { resolveStaticConfig, readConfigFile, - readEnv, + readEnvConfig, readSecret, } from './lib'; @@ -102,7 +102,7 @@ export async function loadConfig( ); } - configs.push(...readEnv(process.env)); + configs.push(...readEnvConfig(process.env)); return configs; } From 35e1af6efaf31811d140f4c4eb01896d94a9063d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 17 Aug 2020 11:51:13 +0200 Subject: [PATCH 4/8] plugins/app-backend: add env config injection --- plugins/app-backend/package.json | 2 + plugins/app-backend/src/lib/config.test.ts | 136 ++++++++++++++++++ plugins/app-backend/src/lib/config.ts | 74 ++++++++++ .../app-backend/src/service/router.test.ts | 2 + plugins/app-backend/src/service/router.ts | 7 + 5 files changed, 221 insertions(+) create mode 100644 plugins/app-backend/src/lib/config.test.ts create mode 100644 plugins/app-backend/src/lib/config.ts diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json index a26667ef7c..29902e9806 100644 --- a/plugins/app-backend/package.json +++ b/plugins/app-backend/package.json @@ -22,9 +22,11 @@ }, "dependencies": { "@backstage/backend-common": "^0.1.1-alpha.18", + "@backstage/config-loader": "^0.1.1-alpha.18", "@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" }, diff --git a/plugins/app-backend/src/lib/config.test.ts b/plugins/app-backend/src/lib/config.test.ts new file mode 100644 index 0000000000..16c5600b1d --- /dev/null +++ b/plugins/app-backend/src/lib/config.test.ts @@ -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; +const readFileMock = (fsMock.readFile as unknown) as jest.MockedFunction< + (name: string) => Promise +>; + +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 }); + }); +}); diff --git a/plugins/app-backend/src/lib/config.ts b/plugins/app-backend/src/lib/config.ts new file mode 100644 index 0000000000..0ed56ef722 --- /dev/null +++ b/plugins/app-backend/src/lib/config.ts @@ -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'); +} diff --git a/plugins/app-backend/src/service/router.test.ts b/plugins/app-backend/src/service/router.test.ts index 8910129394..4e0c27c47a 100644 --- a/plugins/app-backend/src/service/router.test.ts +++ b/plugins/app-backend/src/service/router.test.ts @@ -22,6 +22,8 @@ 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'), }; diff --git a/plugins/app-backend/src/service/router.ts b/plugins/app-backend/src/service/router.ts index efb6d4718e..ecc5b4fa4b 100644 --- a/plugins/app-backend/src/service/router.ts +++ b/plugins/app-backend/src/service/router.ts @@ -19,6 +19,7 @@ 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; @@ -37,6 +38,12 @@ export async function createRouter( ); 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 From 5cb3cf7b9064e0c9ba23a540f6ed90189c3fde29 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 17 Aug 2020 12:57:16 +0200 Subject: [PATCH 5/8] backend: install app-backend --- packages/backend/package.json | 2 ++ packages/backend/src/index.ts | 5 ++++- packages/backend/src/plugins/app.ts | 25 +++++++++++++++++++++++++ plugins/app-backend/package.json | 8 ++++---- 4 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 packages/backend/src/plugins/app.ts diff --git a/packages/backend/package.json b/packages/backend/package.json index 9c3c5a5f58..7393ede81e 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -18,9 +18,11 @@ "migrate:create": "knex migrate:make -x ts" }, "dependencies": { + "example-app": "^0.1.1-alpha.20", "@backstage/backend-common": "^0.1.1-alpha.20", "@backstage/catalog-model": "^0.1.1-alpha.20", "@backstage/config": "^0.1.1-alpha.20", + "@backstage/plugin-app-backend": "^0.1.1-alpha.20", "@backstage/plugin-auth-backend": "^0.1.1-alpha.20", "@backstage/plugin-catalog-backend": "^0.1.1-alpha.20", "@backstage/plugin-graphql-backend": "^0.1.1-alpha.20", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 50f59795fc..21c8794b28 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -40,6 +40,7 @@ import sentry from './plugins/sentry'; import proxy from './plugins/proxy'; import techdocs from './plugins/techdocs'; import graphql from './plugins/graphql'; +import app from './plugins/app'; import { PluginEnvironment } from './types'; function makeCreateEnv(loadedConfigs: AppConfig[]) { @@ -71,6 +72,7 @@ async function main() { const sentryEnv = useHotMemoize(module, () => createEnv('sentry')); const techdocsEnv = useHotMemoize(module, () => createEnv('techdocs')); const graphqlEnv = useHotMemoize(module, () => createEnv('graphql')); + const appEnv = useHotMemoize(module, () => createEnv('app')); const service = createServiceBuilder(module) .loadConfig(configReader) @@ -83,7 +85,8 @@ async function main() { .addRouter('/identity', await identity(identityEnv)) .addRouter('/techdocs', await techdocs(techdocsEnv)) .addRouter('/proxy', await proxy(proxyEnv)) - .addRouter('/graphql', await graphql(graphqlEnv)); + .addRouter('/graphql', await graphql(graphqlEnv)) + .addRouter('', await app(appEnv)); await service.start().catch(err => { console.log(err); diff --git a/packages/backend/src/plugins/app.ts b/packages/backend/src/plugins/app.ts new file mode 100644 index 0000000000..c9f7c0622a --- /dev/null +++ b/packages/backend/src/plugins/app.ts @@ -0,0 +1,25 @@ +/* + * 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 { createRouter } from '@backstage/plugin-app-backend'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin({ logger }: PluginEnvironment) { + return await createRouter({ + logger, + appPackageName: 'example-app', + }); +} diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json index 29902e9806..61977200d5 100644 --- a/plugins/app-backend/package.json +++ b/plugins/app-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-app-backend", - "version": "0.1.1-alpha.18", + "version": "0.1.1-alpha.20", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,8 +21,8 @@ "mock-data": "./scripts/mock-data.sh" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.18", - "@backstage/config-loader": "^0.1.1-alpha.18", + "@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", @@ -31,7 +31,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.18", + "@backstage/cli": "^0.1.1-alpha.20", "@types/supertest": "^2.0.8", "msw": "^0.19.5", "supertest": "^4.0.2" From 5cc4c55e3899b54dfa263f56c06df9e6d882ed02 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 17 Aug 2020 13:20:25 +0200 Subject: [PATCH 6/8] plugins/app-backend: add installation instructions --- plugins/app-backend/README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/plugins/app-backend/README.md b/plugins/app-backend/README.md index 28480b81a1..5d290093d7 100644 --- a/plugins/app-backend/README.md +++ b/plugins/app-backend/README.md @@ -1,3 +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 build 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. From e405fff068c38788dfebd1e0ef9f41db903574dd Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 27 Aug 2020 07:25:16 +0200 Subject: [PATCH 7/8] plugins/app-backend: remove mock-data script --- plugins/app-backend/package.json | 3 +-- plugins/app-backend/scripts/mock-data.sh | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) delete mode 100755 plugins/app-backend/scripts/mock-data.sh diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json index 61977200d5..46cd8dcf25 100644 --- a/plugins/app-backend/package.json +++ b/plugins/app-backend/package.json @@ -17,8 +17,7 @@ "test": "backstage-cli test", "prepack": "backstage-cli prepack", "postpack": "backstage-cli postpack", - "clean": "backstage-cli clean", - "mock-data": "./scripts/mock-data.sh" + "clean": "backstage-cli clean" }, "dependencies": { "@backstage/backend-common": "^0.1.1-alpha.20", diff --git a/plugins/app-backend/scripts/mock-data.sh b/plugins/app-backend/scripts/mock-data.sh deleted file mode 100755 index ff30921715..0000000000 --- a/plugins/app-backend/scripts/mock-data.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/usr/bin/env bash -echo "use this script to load your service with some mock data if needed!" From 8c33d30705ca327aa4d9e345d272dea14d2f40e1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 27 Aug 2020 07:28:27 +0200 Subject: [PATCH 8/8] plugins/app-backend: fix typo --- plugins/app-backend/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/app-backend/README.md b/plugins/app-backend/README.md index 5d290093d7..2dc302accb 100644 --- a/plugins/app-backend/README.md +++ b/plugins/app-backend/README.md @@ -10,7 +10,7 @@ Add both this package and your local frontend app package as dependencies to you yarn add @backstage/plugin-app-backend example-app ``` -By adding the app package as a dependency we ensure that it is build as part of the backend, and that it can be resolved at runtime. +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: