diff --git a/.eslintrc.js b/.eslintrc.js index 02a0df8..71e9eaf 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -29,6 +29,7 @@ module.exports = { "import/extensions": 'off', "import/prefer-default-export": 'off', "no-underscore-dangle": 'off', + "import/no-extraneous-dependencies": 'off' }, overrides: [ { @@ -36,6 +37,7 @@ module.exports = { '*.spec.ts', ], rules: { + 'mocha/no-mocha-arrows': 'off', 'no-unused-expressions': 'off', 'func-names': 'off', 'prefer-arrow-callback': 'off', diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 95ad55b..51c6715 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -49,7 +49,7 @@ jobs: run: yarn install - name: Lint run: yarn lint - - name: Lint + - name: Test run: yarn test - name: Build run: yarn build diff --git a/index.d.ts b/index.d.ts index e5cf755..fcb073f 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,3 +1 @@ -export * from './types/parse-files' -export * from './types/plugin' -export * from './types/routes' \ No newline at end of file +export * from './types'; diff --git a/index.md b/index.md index 6531b62..5d07e70 100644 --- a/index.md +++ b/index.md @@ -5,7 +5,7 @@ A plugin that allows you to render AdminBro by Firebase Cloud Functions Before you start make sure you have the firebase app set up (https://firebase.google.com/docs/functions/get-started): -```bash +```sh yarn global add firebase-tools firebase login firebase init functions @@ -99,25 +99,30 @@ and in a minute you will see your app on Google Cloud Functions for Firebase ## Do this even better. -AdminBro serves 4 assets: +AdminBro serves 4 major assets: + - global.bundle.js which contains react, redux, axios etc. - design-system.bundle.js with AdminBro Design System - app.bundle.js where the entire AdminBro frontend application resides - components.bundle.js - this is the place for bundled (with {@link AdminBro.bundle}) custom components (admin.initialize(); creates it in `./adminbro/bundle.js`) -So it means that your function will have to serve these 4 assets every time the user -opens the page with a web browser. +And 2 less important: `logo` and `favicon` which can be changed in the {@link AdminBroOptions}. + +So it means that your function will have to serve these all assets every time the user +opens the page with a web browser - meaning more function calls and cold start problems. + +You can change that by setting {@link AdminBroOptions.assetsCDN} to bypass serving assets right +from AdminBro. -You can change that by setting {@link AdminBroOptions.assetsCDN}. So before the deployment -you can copy those files to the public directory and host this directory via firebase hosting. -Next point {@link AdminBroOptions.assetsCDN} to the hosting URL and you will save this 3 -extra calls to your function. +Before the deployment you can copy those files to the /public directory and host this directory +via firebase hosting. Next point {@link AdminBroOptions.assetsCDN} to the hosting URL and you will +save these extra calls to your function. First, you will need to add [firebase hosting]{@link https://firebase.google.com/docs/hosting} -to your app and set it up to host files from ./public directory +to your app and set it up to host files from ./public directory. -This is how updated ./bin/bundle.js could look like: +Next, we have to update ./bin/bundle.js to copy assets to the `/public` folder: ```javascript const AdminBro = require('admin-bro'); @@ -149,8 +154,39 @@ admin.initialize().then(() => { }) ``` -and updated deploy script: +> This script doesn't create a folder so you have to `mkdir` it manually. + +Finally updated deploy script: ```sh "deploy": "yarn bundle && firebase deploy --only functions,hosting" ``` + +Also, you will have to update your `firebase.json` with information about the hosting page. + +## Custom domain + +So let's assume that you have a `rootUrl` set to `/` in AdminBro. Your function target name, +(how you name your export) is `app`. + +So the root of the page will be: `YOUR-FUNCTION-HOST/app/`. + +Depending on the environment, (emulator or an actual firebase domain) `@admin-bro/firebase-functions` +will properly adjust the path, that AdminBro knows where to redirect users +(not to `/` but to `/app`). + +In such a case, you don't need to do anything, + +But now you are adding a reverse prox, which redirects traffic from `your-domain.com/app` to +`YOUR-FUNCTION-HOST/app`. + +And now admin does not know how to build the URL because he thinks that, requests are not namespaces +(not from the firebase domain). + +So we have to tell AdminBro that, to the `rootUrl` he has to prepend the `customFunctionPath`. + +CustomPropertyPath should be `app` because `path` going from firebase to admin will be `app/` but +admin is waiting for `/` (rootUrl). + + +`customPropertyPath` is a member of {@link BuildHandlerOptions} \ No newline at end of file diff --git a/package.json b/package.json index c2fcd33..0bb2679 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@admin-bro/firebase-functions", - "version": "3.0.1", + "version": "3.1.0-beta.6", "description": "Firebase plugin for AdminBro", "main": "index.js", "types": "index.d.ts", @@ -9,6 +9,8 @@ "license": "MIT", "scripts": { "build": "tsc", + "dev": "yarn build --watch", + "check:all": "yarn lint && yarn test && yarn build", "lint": "eslint './src/**/*.ts'", "test": "mocha -r ts-node/register src/**/*.spec.ts", "release": "semantic-release" diff --git a/src/index.ts b/src/index.ts index ec8cd66..4f38329 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,2 @@ -export * from './parse-files'; export * from './plugin'; -export * from './routes'; +export * from './utils'; diff --git a/src/plugin.ts b/src/plugin.ts index 93fe559..a87ba4c 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,60 +1,17 @@ // eslint-disable-next-line import/no-extraneous-dependencies -import { Response } from 'firebase-functions'; // eslint-disable-next-line import/no-extraneous-dependencies -import { Request } from 'firebase-functions/lib/providers/https'; -import AdminBro, { AdminBroOptions, CurrentAdmin } from 'admin-bro'; +import AdminBro, { AdminBroOptions } from 'admin-bro'; import { resolve } from 'path'; import { match } from 'path-to-regexp'; import cookie from 'cookie'; import jwt from 'jsonwebtoken'; +import { computeRootPaths } from './utils/compute-root-paths'; +import { prepareComparePath } from './utils/prepare-compare-path'; -import { AppRoutes, AppAssets } from './routes'; -import { parseFiles, cleanFiles, File } from './parse-files'; -/** - * @alias BuildHandlerReturn - * - * @memberof module:@admin-bro/firebase-functions - */ -export type BuildHandlerReturn = ((req: Request, resp: Response) => Promise) - -/** - * @alias BuildHandlerOptions - * - * @memberof module:@admin-bro/firebase-functions - */ -export type BuildHandlerOptions = { - /** Region where function is deployed */ - region: string; - /** - * Optional before `async` hook which can be used to initialize database. - * if it returns something it will be used as AdminBroOptions. - */ - before?: () => Promise | AdminBroOptions | undefined | null; - /** - * custom authentication option. If given AdminBro will render login page - */ - auth?: { - /** - * secret which is used to encrypt the session cookie - */ - secret: string; - /** - * authenticate function - */ - authenticate: ( - email: string, - password: string - ) => Promise | CurrentAdmin | null; - - /** - * For how long cookie session will be stored. - * Default to 900000 (15 minutes). - * In milliseconds. - */ - maxAge?: number; - }; -} +import { AppRoutes, AppAssets } from './utils/routes'; +import { parseFiles, cleanFiles, File } from './utils/parse-files'; +import { BuildHandlerOptions, BuildHandlerReturn } from './utils/build-handler-options'; const DEFAULT_MAX_AGE = 900000; @@ -89,13 +46,9 @@ export const buildHandler = ( ): BuildHandlerReturn => { let admin: AdminBro; - let rootPath: string; let loginPath: string; let logoutPath: string; - - const domain = process.env.FUNCTIONS_EMULATOR - ? `${process.env.GCLOUD_PROJECT}/${options.region}/${process.env.FUNCTION_TARGET}` - : process.env.FUNCTION_TARGET; + let rootPath: string; return async (req, res): Promise => { if (!admin) { @@ -105,17 +58,20 @@ export const buildHandler = ( } admin = new AdminBro(beforeResult || adminOptions); - ({ rootPath, loginPath, logoutPath } = admin.options); - - admin.options.rootPath = `/${domain}${rootPath}`; - admin.options.loginPath = `/${domain}${loginPath}`; - admin.options.logoutPath = `/${domain}${logoutPath}`; + // we have to store original values + ({ loginPath, logoutPath, rootPath } = admin.options); + + Object.assign(admin.options, computeRootPaths(admin.options, { + project: process.env.GCLOUD_PROJECT as string, + region: options.region, + target: process.env.FUNCTION_TARGET as string, + emulator: process.env.FUNCTIONS_EMULATOR, + }, options.customFunctionPath)); } const { method, query } = req; - let path = req.originalUrl.replace(admin.options.rootPath, ''); - if (!path.startsWith('/')) { path = `/${path}`; } - + const path = prepareComparePath(req.path, rootPath, options.customFunctionPath); + const cookies = cookie.parse(req.headers.cookie || ''); const token = cookies && cookies.__session; @@ -125,7 +81,7 @@ export const buildHandler = ( if (options.auth) { const matchLogin = match(loginPath); - if (matchLogin(req.path)) { + if (matchLogin(path)) { if (method === 'GET') { res.send(await admin.renderLogin({ action: admin.options.loginPath, @@ -151,7 +107,7 @@ export const buildHandler = ( } const matchLogout = match(logoutPath); - if (matchLogout(req.path)) { + if (matchLogout(path)) { res.cookie('__session', ''); res.redirect(admin.options.loginPath); return; @@ -199,7 +155,7 @@ export const buildHandler = ( } const asset = AppAssets.find((r) => r.match(path)); - if (asset) { + if (asset && !admin.options.assetsCDN) { res.status(200).sendFile(resolve(asset.src)); return; } diff --git a/src/utils/build-handler-options.ts b/src/utils/build-handler-options.ts new file mode 100644 index 0000000..6035ff8 --- /dev/null +++ b/src/utils/build-handler-options.ts @@ -0,0 +1,57 @@ +import { Response } from 'firebase-functions'; +import { Request } from 'firebase-functions/lib/providers/https'; +import { AdminBroOptions, CurrentAdmin } from 'admin-bro'; + +/** + * @alias BuildHandlerReturn + * + * @memberof module:@admin-bro/firebase-functions + */ + +export type BuildHandlerReturn = ((req: Request, resp: Response) => Promise); +/** + * @alias BuildHandlerOptions + * + * @memberof module:@admin-bro/firebase-functions + */ + +export type BuildHandlerOptions = { + /** Region where function is deployed */ + region: string; + /** + * Optional before `async` hook which can be used to initialize database. + * if it returns something it will be used as AdminBroOptions. + */ + before?: () => Promise | AdminBroOptions | undefined | null; + /** + * custom authentication option. If given AdminBro will render login page + */ + auth?: { + /** + * secret which is used to encrypt the session cookie + */ + secret: string; + /** + * authenticate function + */ + authenticate: ( + email: string, + password: string + ) => Promise | CurrentAdmin | null; + + /** + * For how long cookie session will be stored. + * Default to 900000 (15 minutes). + * In milliseconds. + */ + maxAge?: number; + }; + + /** + * Adjustment path when you proxy the domain. Use case: you proxy `your-domain.com/app` to admin + * firebase function with admin having `rootUrl=='/'` then you have to tell admin that all `paths` + * he receives are `/app` namespaced so he can properly resolve them. In such case + * `customFunctionPath` should be set to `app` because proxy path - rootUrl === 'app'. + */ + customFunctionPath?: string; +}; diff --git a/src/utils/compute-root-paths.spec.ts b/src/utils/compute-root-paths.spec.ts new file mode 100644 index 0000000..f1289c0 --- /dev/null +++ b/src/utils/compute-root-paths.spec.ts @@ -0,0 +1,132 @@ +import { expect } from 'chai'; +import { + ComputeRootPathEnv, + AdminPathOptions, + computeRootPaths, + getLocalhostPathForEnv, +} from './compute-root-paths'; + +/* eslint-disable mocha/no-mocha-arrows */ +describe('routeMatch', () => { + let env: ComputeRootPathEnv = { + emulator: 'true', + project: 'admin-bro-app', + region: 'us-east-1', + target: 'admin', + }; + let options: AdminPathOptions; + let customFunctionPath: string | undefined; + + beforeEach(() => { + env = { + emulator: 'true', + project: 'admin-bro-app', + region: 'us-east-1', + target: 'admin', + }; + + options = { + rootPath: '/admin', + loginPath: '/admin/login', + logoutPath: '/admin/logout', + }; + customFunctionPath = undefined; + }); + + context('run on localhost', () => { + let functionLocalPath: string; + + beforeEach(() => { + env.emulator = 'true'; + functionLocalPath = getLocalhostPathForEnv(env); + }); + + it('fixes root admin paths that they have function url', () => { + const paths = computeRootPaths(options, env, customFunctionPath); + + expect(paths.rootPath).to.equal(`/${functionLocalPath}/admin`); + expect(paths.loginPath).to.equal(`/${functionLocalPath}/admin/login`); + expect(paths.logoutPath).to.equal(`/${functionLocalPath}/admin/logout`); + }); + + it('it is not affected by customFunctionPath', () => { + const paths = computeRootPaths(options, env, 'customDomain'); + + expect(paths.rootPath).to.equal(`/${functionLocalPath}/admin`); + expect(paths.loginPath).to.equal(`/${functionLocalPath}/admin/login`); + expect(paths.logoutPath).to.equal(`/${functionLocalPath}/admin/logout`); + }); + + it('works when adminRoot is set to "/"', () => { + options = { + rootPath: '/', + loginPath: '/login', + logoutPath: '/logout', + }; + + const paths = computeRootPaths(options, env); + + expect(paths.rootPath).to.equal(`/${functionLocalPath}`); + expect(paths.loginPath).to.equal(`/${functionLocalPath}/login`); + expect(paths.logoutPath).to.equal(`/${functionLocalPath}/logout`); + }); + }); + + context('run on default host without custom domain', () => { + beforeEach(() => { + env.emulator = undefined; + customFunctionPath = undefined; + }); + + it('changes urls by adding the target domain', () => { + const paths = computeRootPaths(options, env); + + expect(paths.rootPath).to.equal(`/${env.target}/admin`); + expect(paths.loginPath).to.equal(`/${env.target}/admin/login`); + expect(paths.logoutPath).to.equal(`/${env.target}/admin/logout`); + }); + + it('works when adminRoot is set to "/"', () => { + options = { + rootPath: '/', + loginPath: '/login', + logoutPath: '/logout', + }; + + const paths = computeRootPaths(options, env); + + expect(paths.rootPath).to.equal(`/${env.target}`); + expect(paths.loginPath).to.equal(`/${env.target}/login`); + expect(paths.logoutPath).to.equal(`/${env.target}/logout`); + }); + }); + + context('run on host with custom domain', () => { + beforeEach(() => { + env.emulator = undefined; + customFunctionPath = 'app'; + }); + + it('changes urls by adding the target domain', () => { + const paths = computeRootPaths(options, env, customFunctionPath); + + expect(paths.rootPath).to.equal(`/${customFunctionPath}/admin`); + expect(paths.loginPath).to.equal(`/${customFunctionPath}/admin/login`); + expect(paths.logoutPath).to.equal(`/${customFunctionPath}/admin/logout`); + }); + + it('works when adminRoot is set to "/"', () => { + options = { + rootPath: '/', + loginPath: '/login', + logoutPath: '/logout', + }; + + const paths = computeRootPaths(options, env, customFunctionPath); + + expect(paths.rootPath).to.equal(`/${customFunctionPath}`); + expect(paths.loginPath).to.equal(`/${customFunctionPath}/login`); + expect(paths.logoutPath).to.equal(`/${customFunctionPath}/logout`); + }); + }); +}); diff --git a/src/utils/compute-root-paths.ts b/src/utils/compute-root-paths.ts new file mode 100644 index 0000000..840e150 --- /dev/null +++ b/src/utils/compute-root-paths.ts @@ -0,0 +1,68 @@ +import path from 'path'; +import { AdminBroOptionsWithDefault, Router } from 'admin-bro'; +/* eslint-disable no-param-reassign */ + +export type ComputeRootPathEnv = { + project: string; + region: string; + target: string; + emulator?: string; +} + +export type ComputedPaths = { + rootPath: string; + loginPath: string; + logoutPath: string; +} + +export type AdminPathOptions = Pick + +export const getLocalhostPathForEnv = (env: ComputeRootPathEnv): string => ( + `${env.project}/${env.region}/${env.target}` +); + +const joinPaths = (...paths: Array): string => { + // replace fixes windows paths + const targetPath = path.join('/', ...paths).replace(/\\/g, '/'); + if (targetPath.endsWith('/')) { + return targetPath.slice(0, -1); + } + return targetPath; +}; + +/** + * Function which takes admin bro options and fix paths depending on the environment where it is + * hosted. + * + * @param {AdminBroOptions} options options will can be mutated + * @private + */ +export const computeRootPaths = ( + options: AdminPathOptions, + env: ComputeRootPathEnv, + customFunctionPath?: string, +): ComputedPaths => { + let firebaseRootPath: string; + + if (env.emulator) { + firebaseRootPath = getLocalhostPathForEnv(env); + } else { + firebaseRootPath = customFunctionPath || env.target; + } + + const { rootPath, loginPath, logoutPath } = options; + + return { + rootPath: joinPaths('/', firebaseRootPath, rootPath), + loginPath: joinPaths('/', firebaseRootPath, loginPath), + logoutPath: joinPaths('/', firebaseRootPath, logoutPath), + }; +}; + +export type RouteMatchReturn = { + isLogin: boolean; + isLogout: boolean; + route: (typeof Router)['routes'][number] | null; + asset: (typeof Router)['assets'][number] | null; + // asset?: +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..25ce938 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,5 @@ +export * from './compute-root-paths'; +export * from './parse-files'; +export * from './routes'; +export * from './prepare-compare-path'; +export * from './build-handler-options'; diff --git a/src/parse-files.spec.ts b/src/utils/parse-files.spec.ts similarity index 100% rename from src/parse-files.spec.ts rename to src/utils/parse-files.spec.ts diff --git a/src/parse-files.ts b/src/utils/parse-files.ts similarity index 100% rename from src/parse-files.ts rename to src/utils/parse-files.ts diff --git a/src/utils/prepare-compare-path.spec.ts b/src/utils/prepare-compare-path.spec.ts new file mode 100644 index 0000000..9dbacdd --- /dev/null +++ b/src/utils/prepare-compare-path.spec.ts @@ -0,0 +1,44 @@ +import { expect } from 'chai'; + +import { prepareComparePath } from './prepare-compare-path'; + +describe('prepareComparePath', () => { + let firebasePath: string; + let adminOriginalRootPath: string; + let customFunctionPath: string; + + it('strips path for standard admin path', () => { + firebasePath = '/admin/login'; + adminOriginalRootPath = '/admin'; + + expect(prepareComparePath(firebasePath, adminOriginalRootPath)).to.eq('/login'); + }); + + it('strips path admin path as "/"', () => { + firebasePath = '/login'; + adminOriginalRootPath = '/'; + + expect(prepareComparePath(firebasePath, adminOriginalRootPath)).to.eq('/login'); + }); + + context('custom domain has been set with the proxy /debugFnc to the app', () => { + beforeEach('', () => { + firebasePath = '/debugFnc/login'; + adminOriginalRootPath = '/'; + customFunctionPath = 'debugFnc'; + }); + + it('returns correct path', () => { + const path = prepareComparePath(firebasePath, adminOriginalRootPath, customFunctionPath); + + expect(path).to.eq('/login'); + }); + + it('returns correct path for customPath containing dash', () => { + customFunctionPath = '/debugFnc'; + const path = prepareComparePath(firebasePath, adminOriginalRootPath, customFunctionPath); + + expect(path).to.eq('/login'); + }); + }); +}); diff --git a/src/utils/prepare-compare-path.ts b/src/utils/prepare-compare-path.ts new file mode 100644 index 0000000..361416b --- /dev/null +++ b/src/utils/prepare-compare-path.ts @@ -0,0 +1,30 @@ +/** + * When request hits the server it contains `req.path` equals to what was written in the URL, after + * the "function prefix" like this. + * + * ```json + * {"path": "/admin/api/resources/User/records/123/show"} + * ``` + * + * But when we compare path against particular route, we are comparing just the part after the + * {@link AdminBroOptions.rootPath} defined by the user. + * So this part: `/api/resources/User/records/123/show` is what interest us. + * + * This function takes one and converts it to another. + + * @private + */ +export const prepareComparePath = ( + firebasePath: string, + adminOriginalRootPath: string, + customFunctionPath?: string, +): string => { + let parsedPath = firebasePath; + if (customFunctionPath) { + parsedPath = parsedPath.replace(customFunctionPath, ''); + } + parsedPath = parsedPath.replace(adminOriginalRootPath, ''); + + if (!parsedPath.startsWith('/')) { parsedPath = `/${parsedPath}`; } + return parsedPath; +}; diff --git a/src/routes.ts b/src/utils/routes.ts similarity index 100% rename from src/routes.ts rename to src/utils/routes.ts