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 91b1af06fb..6df593182d 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, '/proxy')) - .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/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', 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; } 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..2dc302accb --- /dev/null +++ b/plugins/app-backend/README.md @@ -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. diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json new file mode 100644 index 0000000000..46cd8dcf25 --- /dev/null +++ b/plugins/app-backend/package.json @@ -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" + ] +} 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/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/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..4e0c27c47a --- /dev/null +++ b/plugins/app-backend/src/service/router.test.ts @@ -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); + }); +}); diff --git a/plugins/app-backend/src/service/router.ts b/plugins/app-backend/src/service/router.ts new file mode 100644 index 0000000000..ecc5b4fa4b --- /dev/null +++ b/plugins/app-backend/src/service/router.ts @@ -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 { + 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; +} 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 {};