diff --git a/packages/subapp-server/lib/fastify-plugin.js b/packages/subapp-server/lib/fastify-plugin.js new file mode 100644 index 000000000..b32de1519 --- /dev/null +++ b/packages/subapp-server/lib/fastify-plugin.js @@ -0,0 +1,104 @@ +"use strict"; + +/* eslint-disable no-magic-numbers, max-statements */ + +const _ = require("lodash"); +const HttpStatus = require("./http-status"); +const subAppUtil = require("subapp-util"); +const HttpStatusCodes = require("http-status-codes"); + +const { makeErrorStackResponse } = require("./utils"); +const { getSrcDir, setupRouteRender, searchRoutesFromFile } = require("./setup-hapi-routes"); + +module.exports = { + fastifyPlugin: async (fastify, pluginOpts) => { + const srcDir = getSrcDir(pluginOpts); + + // TODO: + // const fromDir = await searchRoutesDir(srcDir, pluginOpts); + // if (fromDir) { + // // + // } + + const { routes, topOpts } = searchRoutesFromFile(srcDir, pluginOpts); + + const subApps = await subAppUtil.scanSubAppsFromDir(srcDir); + const subAppsByPath = subAppUtil.getSubAppByPathMap(subApps); + + const makeRouteHandler = (path, route) => { + const routeOptions = Object.assign({}, topOpts, route); + + const routeRenderer = setupRouteRender({ subAppsByPath, srcDir, routeOptions }); + const useStream = routeOptions.useStream !== false; + + return async (request, reply) => { + try { + const context = await routeRenderer({ + content: { + html: "", + status: 200, + useStream + }, + mode: "", + request + }); + + const data = context.result; + const status = data.status; + if (data instanceof Error) { + // rethrow to get default error behavior below with helpful errors in dev mode + throw data; + } else if (status === undefined) { + reply.type("text/html; charset=UTF-8").code(HttpStatusCodes.OK); + return reply.send(data); + } else if (HttpStatus.redirect[status]) { + return reply.redirect(status, data.path); + } else if ( + HttpStatus.displayHtml[status] || + (status >= HttpStatusCodes.OK && status < 300) + ) { + reply.type("text/html; charset=UTF-8").code(status); + return reply.send(data.html !== undefined ? data.html : data); + } else { + reply.code(status); + return reply.send(data); + } + } catch (err) { + reply.status(HttpStatusCodes.INTERNAL_SERVER_ERROR); + if (process.env.NODE_ENV !== "production") { + const responseHtml = makeErrorStackResponse(path, err); + reply.type("text/html; charset=UTF-8"); + return reply.send(responseHtml); + } else { + return reply.send("Internal Server Error"); + } + } + }; + }; + + for (const path in routes) { + const route = routes[path]; + + const handler = makeRouteHandler(path, route); + + const defaultMethods = [].concat(route.methods || "get"); + const paths = _.uniq([path].concat(route.paths).filter(x => x)).map(x => { + if (typeof x === "string") { + return { [x]: defaultMethods }; + } + return x; + }); + + paths.forEach(pathObj => { + _.each(pathObj, (method, xpath) => { + fastify.route({ + ...route.settings, + path: xpath, + method: method.map(x => x.toUpperCase()), + handler + }); + }); + }); + } + } +}; diff --git a/packages/subapp-server/lib/index.js b/packages/subapp-server/lib/index.js index 2c617a935..8c3f73251 100644 --- a/packages/subapp-server/lib/index.js +++ b/packages/subapp-server/lib/index.js @@ -1,5 +1,6 @@ "use strict"; const hapiPlugin = require("./hapi-plugin"); +const { fastifyPlugin } = require("./fastify-plugin"); -module.exports = { hapiPlugin }; +module.exports = { hapiPlugin, fastifyPlugin }; diff --git a/packages/subapp-server/lib/setup-hapi-routes.js b/packages/subapp-server/lib/setup-hapi-routes.js index 2ebc3dcdc..efdacf455 100644 --- a/packages/subapp-server/lib/setup-hapi-routes.js +++ b/packages/subapp-server/lib/setup-hapi-routes.js @@ -98,41 +98,40 @@ async function handleFavIcon(server, options) { }); } -async function setupRoutesFromFile(srcDir, server, pluginOpts) { - // there should be a src/routes.js file with routes spec - const { loadRoutesFrom } = pluginOpts; +function setupRouteRender({ subAppsByPath, srcDir, routeOptions }) { + updateFullTemplate(routeOptions.dir, routeOptions); + const chunkSelector = resolveChunkSelector(routeOptions); + routeOptions.__internals = { chunkSelector }; + + // load subapps for the route + if (routeOptions.subApps) { + routeOptions.__internals.subApps = [].concat(routeOptions.subApps).map(x => { + let options = {}; + if (Array.isArray(x)) { + options = x[1]; + x = x[0]; + } + // absolute: use as path + // else: assume dir under srcDir + // TBD: handle it being a module + return { + subapp: subAppsByPath[Path.isAbsolute(x) ? x : Path.resolve(srcDir, x)], + options + }; + }); + } - const routesFile = [ - loadRoutesFrom && Path.resolve(srcDir, loadRoutesFrom), - Path.resolve(srcDir, "routes") - ].find(x => x && optionalRequire(x)); + // const useStream = routeOptions.useStream !== false; - const spec = routesFile ? require(routesFile) : {}; + const routeHandler = ReactWebapp.makeRouteHandler(routeOptions); + + return routeHandler; +} +async function registerHapiRoutes({ server, srcDir, routes, topOpts }) { const subApps = await subAppUtil.scanSubAppsFromDir(srcDir); const subAppsByPath = subAppUtil.getSubAppByPathMap(subApps); - const topOpts = _.merge( - getDefaultRouteOptions(), - { dir: Path.resolve(srcDir) }, - _.omit(spec, ["routes", "default"]), - pluginOpts - ); - - topOpts.routes = _.merge({}, spec.routes || spec.default, topOpts.routes); - - await handleFavIcon(server, topOpts); - - // routes can either be in default (es6) or routes - const routes = topOpts.routes; - - // invoke setup callback - for (const path in routes) { - if (routes[path].setup) { - await routes[path].setup(server); - } - } - // setup for initialize callback server.ext("onPreAuth", async (request, h) => { const rte = routes[request.route.path]; @@ -143,47 +142,23 @@ async function setupRoutesFromFile(srcDir, server, pluginOpts) { return h.continue; }); - // in case needed, add full protocol/host/port to dev bundle base URL - topOpts.devBundleBase = subAppUtil.formUrl({ - ..._.pick(topOpts.devServer, ["protocol", "host", "port"]), - path: topOpts.devBundleBase - }); - // register routes for (const path in routes) { const route = routes[path]; - const routeOptions = Object.assign({}, topOpts, route); - updateFullTemplate(routeOptions.dir, routeOptions); - const chunkSelector = resolveChunkSelector(routeOptions); - routeOptions.__internals = { chunkSelector }; - - // load subapps for the route - if (routeOptions.subApps) { - routeOptions.__internals.subApps = [].concat(routeOptions.subApps).map(x => { - let options = {}; - if (Array.isArray(x)) { - options = x[1]; - x = x[0]; - } - // absolute: use as path - // else: assume dir under srcDir - // TBD: handle it being a module - return { - subapp: subAppsByPath[Path.isAbsolute(x) ? x : Path.resolve(srcDir, x)], - options - }; - }); - } + const routeRenderer = setupRouteRender({ subAppsByPath, srcDir, routeOptions }); const useStream = routeOptions.useStream !== false; - const routeHandler = ReactWebapp.makeRouteHandler(routeOptions); const handler = async (request, h) => { try { - const context = await routeHandler({ - content: { html: "", status: 200, useStream }, + const context = await routeRenderer({ + content: { + html: "", + status: 200, + useStream + }, mode: "", request }); @@ -205,7 +180,12 @@ async function setupRoutesFromFile(srcDir, server, pluginOpts) { return h.response(data).code(status); } } catch (err) { - return errorResponse({ routeName: path, request, h, err }); + return errorResponse({ + routeName: path, + request, + h, + err + }); } }; @@ -219,12 +199,70 @@ async function setupRoutesFromFile(srcDir, server, pluginOpts) { paths.forEach(pathObj => { _.each(pathObj, (method, xpath) => { - server.route(Object.assign({}, route.settings, { path: xpath, method, handler })); + server.route( + Object.assign({}, route.settings, { + path: xpath, + method, + handler + }) + ); }); }); } } +function searchRoutesFromFile(srcDir, pluginOpts) { + // there should be a src/routes.js file with routes spec + const { loadRoutesFrom } = pluginOpts; + + const routesFile = [ + loadRoutesFrom && Path.resolve(srcDir, loadRoutesFrom), + Path.resolve(srcDir, "routes") + ].find(x => x && optionalRequire(x)); + + const spec = routesFile ? require(routesFile) : {}; + + const topOpts = _.merge( + getDefaultRouteOptions(), + { dir: Path.resolve(srcDir) }, + _.omit(spec, ["routes", "default"]), + pluginOpts + ); + + topOpts.routes = _.merge({}, spec.routes || spec.default, topOpts.routes); + + // routes can either be in default (es6) or routes + const routes = topOpts.routes; + + // in case needed, add full protocol/host/port to dev bundle base URL + topOpts.devBundleBase = subAppUtil.formUrl({ + ..._.pick(topOpts.devServer, ["protocol", "host", "port"]), + path: topOpts.devBundleBase + }); + + return { routes, topOpts }; +} + +async function setupRoutesFromFile(srcDir, server, pluginOpts) { + const { routes, topOpts } = searchRoutesFromFile(srcDir, server, pluginOpts); + + await handleFavIcon(server, topOpts); + + // invoke setup callback + for (const path in routes) { + if (routes[path].setup) { + await routes[path].setup(server); + } + } + + await registerHapiRoutes({ + server, + routes, + topOpts, + srcDir + }); +} + async function setupRoutesFromDir(server, pluginOpts, fromDir) { const { routes } = fromDir; @@ -264,11 +302,16 @@ async function setupRoutesFromDir(server, pluginOpts, fromDir) { registerRoutes({ routes, topOpts, server }); } -async function setupSubAppHapiRoutes(server, pluginOpts) { - const srcDir = +function getSrcDir(pluginOpts) { + return ( pluginOpts.srcDir || process.env.APP_SRC_DIR || - (process.env.NODE_ENV === "production" ? "lib" : "src"); + (process.env.NODE_ENV === "production" ? "lib" : "src") + ); +} + +async function setupSubAppHapiRoutes(server, pluginOpts) { + const srcDir = getSrcDir(pluginOpts); const fromDir = await searchRoutesDir(srcDir, pluginOpts); if (fromDir) { @@ -280,5 +323,10 @@ async function setupSubAppHapiRoutes(server, pluginOpts) { } module.exports = { - setupSubAppHapiRoutes + getSrcDir, + searchRoutesDir, + searchRoutesFromFile, + setupRoutesFromFile, + setupSubAppHapiRoutes, + setupRouteRender }; diff --git a/packages/subapp-server/lib/utils.js b/packages/subapp-server/lib/utils.js index 7ae2ee1ed..e14465c7a 100644 --- a/packages/subapp-server/lib/utils.js +++ b/packages/subapp-server/lib/utils.js @@ -112,12 +112,16 @@ function cleanStack(stack) { return lines.join("\n"); } +function makeErrorStackResponse(routeName, err) { + const stack = cleanStack(err.stack); + console.error(`Route ${routeName} failed:`, stack); + return `
${stack}`; +} + function errorResponse({ routeName, h, err }) { if (process.env.NODE_ENV !== "production") { - const stack = cleanStack(err.stack); - console.error(`Route ${routeName} failed:`, stack); return h - .response(`
${stack}`) + .response(makeErrorStackResponse(routeName, err)) .type("text/html; charset=UTF-8") .code(HttpStatusCodes.INTERNAL_SERVER_ERROR); } else { @@ -131,5 +135,6 @@ module.exports = { getCriticalCSS, getDefaultRouteOptions, updateFullTemplate, - errorResponse + errorResponse, + makeErrorStackResponse }; diff --git a/packages/xarc-app-dev/lib/dev-admin/admin-server.js b/packages/xarc-app-dev/lib/dev-admin/admin-server.js index 308104ce8..7dee63f42 100644 --- a/packages/xarc-app-dev/lib/dev-admin/admin-server.js +++ b/packages/xarc-app-dev/lib/dev-admin/admin-server.js @@ -564,7 +564,10 @@ ${instruction}` store: [], fullLogUrl: this._fullAppLogUrl, checkLine: str => { - if (!this._fullyStarted && str.includes("server running")) { + if ( + !this._fullyStarted && + (str.includes("server running") || str.includes("Server listening")) + ) { this._fullyStarted = true; // opens menu automatically once after startup this._shutdown || diff --git a/packages/xarc-app-dev/lib/webpack-dev-fastify.js b/packages/xarc-app-dev/lib/webpack-dev-fastify.js index 73e7f6f40..29bf4c10b 100644 --- a/packages/xarc-app-dev/lib/webpack-dev-fastify.js +++ b/packages/xarc-app-dev/lib/webpack-dev-fastify.js @@ -1,18 +1,9 @@ "use strict"; /* eslint-disable no-console, no-magic-numbers */ -const archetype = require("@xarc/app/config/archetype"); - const AppDevMiddleware = require("./app-dev-middleware"); async function register(server) { - if (!archetype.webpack.devMiddleware) { - console.error( - "dev-fastify plugin was loaded but WEBPACK_DEV_MIDDLEWARE is not true. Skipping." - ); - return; - } - const middleware = new AppDevMiddleware({}); middleware.setup(); diff --git a/samples/poc-subapp-min-fastify/.browserslistrc b/samples/poc-subapp-min-fastify/.browserslistrc new file mode 100644 index 000000000..d66332951 --- /dev/null +++ b/samples/poc-subapp-min-fastify/.browserslistrc @@ -0,0 +1,4 @@ +# Browsers that we support +last 2 versions +ie >= 11 +> 5% diff --git a/samples/poc-subapp-min-fastify/.eslintrc.js b/samples/poc-subapp-min-fastify/.eslintrc.js new file mode 100644 index 000000000..0d7011c56 --- /dev/null +++ b/samples/poc-subapp-min-fastify/.eslintrc.js @@ -0,0 +1,11 @@ +var path = require("path"); +var archetype = require("@xarc/app/config/archetype"); +var archetypeEslint = path.join(archetype.config.eslint, ".eslintrc-react"); + +function dotify(p) { + return path.isAbsolute(p) ? p : "." + path.sep + p; +} + +module.exports = { + extends: dotify(path.relative(__dirname, archetypeEslint)) +}; diff --git a/samples/poc-subapp-min-fastify/.gitignore b/samples/poc-subapp-min-fastify/.gitignore new file mode 100644 index 000000000..06206dba2 --- /dev/null +++ b/samples/poc-subapp-min-fastify/.gitignore @@ -0,0 +1,160 @@ + + +# Created by https://www.gitignore.io/api/gitbook,node,webstorm,bower,osx + +### GitBook ### +# Node rules: +## Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +## Dependency directory +## Commenting this out is preferred by some people, see +## https://docs.npmjs.com/misc/faq#should-i-check-my-node_modules-folder-into-git +node_modules + +# Book build output +_book + +# eBook build output +*.epub +*.mobi +*.pdf + + +### Node ### +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git +node_modules + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + + +### WebStorm ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm + +*.iml + +## Directory-based project format: +.idea/ +# if you remove the above rule, at least ignore the following: + +# User-specific stuff: +# .idea/workspace.xml +# .idea/tasks.xml +# .idea/dictionaries +# .idea/shelf + +# Sensitive or high-churn files: +# .idea/dataSources.ids +# .idea/dataSources.xml +# .idea/sqlDataSources.xml +# .idea/dynamic.xml +# .idea/uiDesigner.xml + +# Gradle: +# .idea/gradle.xml +# .idea/libraries + +# Mongo Explorer plugin: +# .idea/mongoSettings.xml + +## File-based project format: +*.ipr +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + + +### Bower ### +bower_components +.bower-cache +.bower-registry +.bower-tmp + + +### OSX ### +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# others +.hg +.project +.tmp + +# Build +dist +Procfile +config/assets.json +npm-shrinkwrap.json + +.isomorphic-loader-config.json + +lib + diff --git a/samples/poc-subapp-min-fastify/LICENSE b/samples/poc-subapp-min-fastify/LICENSE new file mode 100644 index 000000000..26518628d --- /dev/null +++ b/samples/poc-subapp-min-fastify/LICENSE @@ -0,0 +1,13 @@ +Copyright 2018-present @WalmartLabs + +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. diff --git a/samples/poc-subapp-min-fastify/README.md b/samples/poc-subapp-min-fastify/README.md new file mode 100644 index 000000000..e526446d8 --- /dev/null +++ b/samples/poc-subapp-min-fastify/README.md @@ -0,0 +1,5 @@ +# subapp poc sample + +## License + +Apache-2.0 © diff --git a/samples/poc-subapp-min-fastify/babel.config.js b/samples/poc-subapp-min-fastify/babel.config.js new file mode 100644 index 000000000..fb1d6601e --- /dev/null +++ b/samples/poc-subapp-min-fastify/babel.config.js @@ -0,0 +1,4 @@ +"use strict"; +module.exports = { + extends: "@xarc/app-dev/config/babel/babelrc.js" +}; diff --git a/samples/poc-subapp-min-fastify/config/default.js b/samples/poc-subapp-min-fastify/config/default.js new file mode 100644 index 000000000..48b25ed20 --- /dev/null +++ b/samples/poc-subapp-min-fastify/config/default.js @@ -0,0 +1,32 @@ +"use strict"; + +const defaultListenPort = 3000; + +const portFromEnv = () => { + const x = parseInt(process.env.APP_SERVER_PORT || process.env.PORT, 10); + /* istanbul ignore next */ + return x !== null && !isNaN(x) ? x : defaultListenPort; +}; + +module.exports = { + plugins: { + "webpack-dev": { + module: "@xarc/app-dev/lib/webpack-dev-fastify", + enable: process.env.WEBPACK_DEV === "true" + }, + "subapp-server": { options: { insertTokenIds: true } } + }, + connections: { + default: { + host: process.env.HOST, + address: process.env.HOST_IP || "0.0.0.0", + port: portFromEnv(), + routes: { + cors: false + }, + state: { + ignoreErrors: true + } + } + } +}; diff --git a/samples/poc-subapp-min-fastify/config/development.js b/samples/poc-subapp-min-fastify/config/development.js new file mode 100644 index 000000000..2c63c0851 --- /dev/null +++ b/samples/poc-subapp-min-fastify/config/development.js @@ -0,0 +1,2 @@ +{ +} diff --git a/samples/poc-subapp-min-fastify/config/production.js b/samples/poc-subapp-min-fastify/config/production.js new file mode 100644 index 000000000..b817aa14e --- /dev/null +++ b/samples/poc-subapp-min-fastify/config/production.js @@ -0,0 +1,19 @@ +// +// This file is here to allow enabling the plugins inert and electrodeStaticPaths, overriding the +// settings in production.json, in order to serve the static JS and CSS bundle files from +// the dist directory so you can test your app server locally in production mode. +// +// When running in a real production environment where your static files are most likely served +// by a dedicated CDN server, you might want to turn these plugins off. +// + +module.exports = { + plugins: { + "subapp-server": { + options: { + insertTokenIds: false, + cdn: { enable: true } + } + } + } +}; diff --git a/samples/poc-subapp-min-fastify/package.json b/samples/poc-subapp-min-fastify/package.json new file mode 100644 index 000000000..310b0cc78 --- /dev/null +++ b/samples/poc-subapp-min-fastify/package.json @@ -0,0 +1,67 @@ +{ + "name": "poc-subapp", + "version": "0.0.1", + "description": "SubApp POC", + "homepage": "http://test", + "author": { + "name": "Joel Chen", + "email": "joel123@gmail.com", + "url": "https://github.com/jchip" + }, + "contributors": [], + "files": [ + "server", + "src", + "lib", + "dist" + ], + "main": "lib/server/index.js", + "keywords": [ + "test", + "electrode" + ], + "repository": { + "type": "git", + "url": "test/test-app" + }, + "license": "Apache-2.0", + "engines": { + "node": ">= 10", + "npm": ">= 6" + }, + "scripts": { + "dev": "clap -q dev", + "test": "clap check", + "build": "clap build" + }, + "dependencies": { + "@xarc/app": "^8.0.0", + "@xarc/fastify-server": "^1.1.0", + "electrode-confippet": "^1.5.0", + "http-codes": "^1.0.0", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-router": "^5.1.0", + "react-router-dom": "^5.1.0", + "subapp-react": "../../packages/subapp-react", + "subapp-server": "../../packages/subapp-server", + "subapp-util": "../../packages/subapp-util" + }, + "devDependencies": { + "@xarc/app-dev": "^8.0.0" + }, + "fyn": { + "dependencies": { + "@xarc/app": "../../packages/xarc-app", + "@xarc/fastify-server": "../../../fastify-server" + }, + "devDependencies": { + "@xarc/app-dev": "../../packages/xarc-app-dev" + } + }, + "prettier": { + "printWidth": 100, + "arrowParens": "avoid", + "trailingComma": "none" + } +} diff --git a/samples/poc-subapp-min-fastify/src/.babelrc.js b/samples/poc-subapp-min-fastify/src/.babelrc.js new file mode 100644 index 000000000..8d166bca2 --- /dev/null +++ b/samples/poc-subapp-min-fastify/src/.babelrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: "@xarc/app-dev/config/babel/babelrc-client.js" +}; diff --git a/samples/poc-subapp-min-fastify/src/demo1/subapp-demo1.js b/samples/poc-subapp-min-fastify/src/demo1/subapp-demo1.js new file mode 100644 index 000000000..a6d097841 --- /dev/null +++ b/samples/poc-subapp-min-fastify/src/demo1/subapp-demo1.js @@ -0,0 +1,12 @@ +import React from "react"; +import { loadSubApp } from "subapp-react"; + +const Demo1 = () => { + return ( +