diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md new file mode 100644 index 000000000..6e754c58d --- /dev/null +++ b/ISSUE_TEMPLATE.md @@ -0,0 +1,43 @@ + + +## Type of issue + + + + + +---- + +## My environment + +* *OS version/details*: `eg. Windows 10 64-bit` +* *Node version:* `x.x.x` (run `node --version` in your terminal) +* *npm version:* `x.x.x` (run `npm --version` in your terminal) +* *Version of yo :* `x.x.x` (run `yo --version` in your terminal) + +## Expected behavior + + + +## Current behavior + + + +## Steps to reproduce the behavior + +## Command line output + +``` +Paste your error output over here +``` diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 49e0fd970..01baba0a2 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -7,6 +7,7 @@ - [What is Electrode](overview/what-is-electrode.md) - [Why Use Electrode](overview/why-use-electrode.md) - [Requirements](overview/requirements.md) + - [What are Archetypes](/overview/what-are-archetypes.md) ### Quick Start - [Start With Electrode App](/chapter1/quick-start/start-with-app.md) @@ -31,7 +32,6 @@ - [Intermediate](chapter1/Intermediate.md) - [Application Archetype](/chapter1/intermediate/app-archetype/README.md) - - [What are Archetypes](/chapter1/intermediate/app-archetype/what-are-archetypes.md) - [Eslint Config](/chapter1/intermediate/app-archetype/eslint.md) - [Webpack Config](/chapter1/intermediate/app-archetype/webpack-config.md) - [Environment Variables](/chapter1/intermediate/app-archetype/env-vars.md) diff --git a/docs/chapter1/intermediate/app-archetype/README.md b/docs/chapter1/intermediate/app-archetype/README.md index 95b76b4ab..d68966747 100644 --- a/docs/chapter1/intermediate/app-archetype/README.md +++ b/docs/chapter1/intermediate/app-archetype/README.md @@ -1,8 +1,7 @@ # Electrode React Application Archetype -You've seen how quickly you can create a React application with Electrode's application archetype. The defaults generally are well suited for many people, but it is possible to customize some aspects for your specific needs. +The application archetype is a pair of npm modules that provide common patterns and configurations for developing a Universal React application. With the application generator, you can quickly create and start developing a React application. The defaults generally are well suited for many people, but it is possible to customize some aspects and enable more advanced features for your specific needs. -- [What Are Archetypes](/chapter1/intermediate/app-archetype/what-are-archetypes.md) - [Eslint Config](/chapter1/intermediate/app-archetype/eslint.md) - [Webpack Config](/chapter1/intermediate/app-archetype/webpack-config.md) - [Environment Variables](/chapter1/intermediate/app-archetype/env-vars.md) diff --git a/docs/chapter1/quick-start/start-with-component.md b/docs/chapter1/quick-start/start-with-component.md index 0965cbce7..c55d7954f 100644 --- a/docs/chapter1/quick-start/start-with-component.md +++ b/docs/chapter1/quick-start/start-with-component.md @@ -35,7 +35,9 @@ A webserver will be started on localhost:3000. Your new component will be used i ## Further: Adding More Components to the Packages -If you want to add more components to your project, go to `packages` directory and run `yo electrode:component-add` within the packages directory. You can skip this section if you do not need extra components. +> Note: You can skip this section if you do not need extra components. + +If you want to add more components to your project, go to `packages` directory and run `yo electrode:component-add` within the packages directory. ```bash $ cd packages @@ -48,6 +50,8 @@ Follow the prompts as above, for example: This will generate a new package and also update the demo-app. Don't get panic if you saw conflicts, the `demo-app/src/client/components/home.jsx` and `demo-app/package.json` expected to be overwritten during the update. +> Note: If you have installed `yo` at version 2.0.0 or higher, you can specify the `a` option for `overwrite this and all others`, or you need to specify the `y` option for `overwrite` each time. + After finished installation, you can preview the multi-components by using demo-app again. ```bash diff --git a/docs/overview/requirements.md b/docs/overview/requirements.md index 066c3e018..c88aaeb25 100644 --- a/docs/overview/requirements.md +++ b/docs/overview/requirements.md @@ -2,23 +2,47 @@ First, let's quickly check your development environment. You will need to have the following set up to generate and deploy your Electrode app|component in under five minutes: -## For Development On Your Local machine +## For Development On Your Local Machine 1. Install the latest [NodeJS LTS binary](https://nodejs.org/) in your machine. (at least v4.2 required, >= 6 recommended). - - Recommend a tool like [nvm](https://github.com/creationix/nvm#install-script) for managing NodeJS installations. -2. Install latest (v3) of [npm](https://www.npmjs.com/) with `npm install -g npm`. - - Note: NodeJS v6.x already comes with npm@3 by default. - - **Electrode requires npm version >= 3** -3. Install [yo] with `npm install -g yo`. -4. Install [generator-electrode] with `npm install -g generator-electrode`. -5. Install [xclap] with `npm install -g xclap-cli`. -6. The text editor of your choice downloaded with CLI tools installed. - -> **We write NodeJS code with ES6 features that NodeJS >= 4.2 supports.** -> -> yo is a generic scaffolding system allowing the creation of any kind of app. -> generator-electrode is a generator for Universal React App with NodeJS backend or a React component with useful clap tasks for development, building and publishing. -> xclap is a Javascript build tool that lets us automate tasks. + + - We recommend a tool like [nvm](https://github.com/creationix/nvm#install-script) for managing NodeJS installations, but see [info here](#globally-installed-nodejs) for cautionary notes. + +2. Install [yo], [generator-electrode], and [xclap-cli] globally: + +```bash +npm install -g yo generator-electrode xclap-cli +``` + +### Globally Installed NodeJS + +IMPORTANT: If you install NodeJS globally on your system, then please make sure you re-install `yo` if it's already exist in your Node installation. A bug in `yo` was causing it to always load generators from your system folders. The fix was released on 7-31-2017. + +### Keep `generator-electrode` Up To Date + +We release new version of [generator-electrode] when we add new features. So make sure you check the versions and update it periodically with the command: + +```bash +npm install -g generator-electrode +``` + +### NPM Verion 3 + +**Electrode requires npm version >= 3** + +NodeJS v6.x already comes with npm@3 by default, but if you are using NodeJS 4 for some reason, make sure you install npm@3 with the following command: + +```bash +npm install -g npm@3 +``` + +### Headless Chrome + +Headless chrome brings all modern web platform features provided by Chromium and the Blink rendering engine to the command line. It is a great tool for automated testing and server environments where you don't need a visible UI shell. + +In the Electrode Archetype App and Component, we are using Chrome Headless as the default option for automated testing. Please go to your Chrome browser, update Google Chrome if you see the option, and relaunch. + +> Note: Headless mode is available on Mac and Linux in Chrome 59. Windows support is coming in Chrome 60. To check what version of Chrome you have, open chrome://version. ## For Online Deployments @@ -26,3 +50,11 @@ First, let's quickly check your development environment. You will need to have t - A [Github](https://github.com/) account. Ready? Let's [build](/chapter1/quick-start/build-component.md). + +[yo]: http://yeoman.io/ + +[yeoman]: http://yeoman.io/ + +[xclap-cli]: https://www.npmjs.com/package/xclap-cli + +[generator-electrode]: https://www.npmjs.com/package/generator-electrode diff --git a/docs/chapter1/intermediate/app-archetype/what-are-archetypes.md b/docs/overview/what-are-archetypes.md similarity index 100% rename from docs/chapter1/intermediate/app-archetype/what-are-archetypes.md rename to docs/overview/what-are-archetypes.md diff --git a/package.json b/package.json index c56f3c090..3b796ee58 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "nuke-packages": "rm -rf packages/*/node_modules packages/*/coverage", "nuke-samples": "rm -rf samples/*/node_modules samples/*/coverage samples/*/dist samples/*/.isomorphic-loader-config.json samples/*/.etmp", "test-reporter": "if lerna updated | grep electrode-webpack-reporter; then cd packages/electrode-webpack-reporter && npm install && npm test; fi", - "update-changelog": "node tools/update-changelog.js" + "update-changelog": "node tools/update-changelog.js", + "gitbook-serve": "gitbook serve --no-watch --no-live" }, "devDependencies": { "bluebird": "^3.5.0", diff --git a/packages/electrode-archetype-react-app-dev/config/webpack/plugins/sw-precache.js b/packages/electrode-archetype-react-app-dev/config/webpack/plugins/sw-precache.js index a37e6d55e..e5d0b60ff 100644 --- a/packages/electrode-archetype-react-app-dev/config/webpack/plugins/sw-precache.js +++ b/packages/electrode-archetype-react-app-dev/config/webpack/plugins/sw-precache.js @@ -3,7 +3,6 @@ /* Service Worker Precache WebPack Plugin */ /* From https://github.com/goldhand/sw-precache-webpack-plugin */ /* Copied locally to turn off warning */ - "use strict"; var _extends = Object.assign; @@ -16,10 +15,6 @@ var _url = require("url"); var _url2 = _interopRequireDefault(_url); -var _del = require("del"); - -var _del2 = _interopRequireDefault(_del); - var _swPrecache = require("sw-precache"); var _swPrecache2 = _interopRequireDefault(_swPrecache); @@ -28,30 +23,32 @@ var _uglifyJs = require("uglify-js"); var _uglifyJs2 = _interopRequireDefault(_uglifyJs); -var _fs = require("fs"); - -var _fs2 = _interopRequireDefault(_fs); +var _util = require("util"); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } const FILEPATH_WARNING = - "sw-prechache-webpack-plugin filepath: You are using a custom path for your service worker, this may prevent the service worker from working correctly if it is not available in the same path as your application."; + "sw-prechache-webpack-plugin [filepath]: You are using a custom path for your service worker, this may prevent the service worker from working correctly if it is not available in the same path as your application."; +const FORCEDELETE_WARNING = + "sw-prechache-webpack-plugin [forceDelete]: You are specifying the option forceDelete. This was removed in v0.10. It should not affect your build but should no longer be required."; const DEFAULT_CACHE_ID = "sw-precache-webpack-plugin", DEFAULT_WORKER_FILENAME = "service-worker.js", - DEFAULT_OUTPUT_PATH = "", DEFAULT_PUBLIC_PATH = "", DEFAULT_IMPORT_SCRIPTS = [], - DEFAULT_IGNORE_PATTERNS = []; + DEFAULT_IGNORE_PATTERNS = [], + CHUNK_NAME_NOT_FOUND_ERROR = 'Could not locate files for chunkName: "%s"', + // eslint-disable-next-line max-len + CHUNK_NAME_OVERRIDES_FILENAME_WARNING = + "Don't use chunkName & filename together; importScripts[].filename overriden by specified chunkName: %j"; const DEFAULT_OPTIONS = { cacheId: DEFAULT_CACHE_ID, filename: DEFAULT_WORKER_FILENAME, importScripts: DEFAULT_IMPORT_SCRIPTS, staticFileGlobsIgnorePatterns: DEFAULT_IGNORE_PATTERNS, - forceDelete: false, mergeStaticsConfig: false, minify: false }; @@ -60,33 +57,15 @@ class SWPrecacheWebpackPlugin { /** * SWPrecacheWebpackPlugin - A wrapper for sw-precache to use with webpack * @constructor - * @param {object} options - All parameters should be passed as a single options object - * - * // sw-precache options: - * @param {string} [options.cacheId] - * @param {string} [options.directoryIndex] - * @param {object|array} [options.dynamicUrlToDependencies] - * @param {boolean} [options.handleFetch] - * @param {array} [options.ignoreUrlParametersMatching] - * @param {array} [options.importScripts] - * @param {function} [options.logger] - * @param {number} [options.maximumFileSizeToCacheInBytes] - * @param {array} [options.navigateFallbackWhitelist] - * @param {string} [options.replacePrefix] - * @param {array} [options.runtimeCaching] - * @param {array} [options.staticFileGlobs] - * @param {string} [options.stripPrefix] - * @param {string} [options.stripPrefixMulti] - * @param {string} [options.templateFilePath] - * @param {boolean} [options.verbose] + * @param {object} options - All parameters should be passed as a single options object. All sw-precache options can be passed here in addition to plugin options. * * // plugin options: * @param {string} [options.filename] - Service worker filename, default is 'service-worker.js' * @param {string} [options.filepath] - Service worker path and name, default is to use webpack.output.path + options.filename * @param {RegExp} [options.staticFileGlobsIgnorePatterns[]] - Define an optional array of regex patterns to filter out of staticFileGlobs - * @param {boolean} [options.forceDelete=false] - Pass force option to del * @param {boolean} [options.mergeStaticsConfig=false] - Merge provided staticFileGlobs and stripPrefix(Multi) with webpack's config, rather than having those take precedence * @param {boolean} [options.minify=false] - Minify the generated Service worker file using UglifyJS + * @param {boolean} [options.debug=false] - Output error and warning messages */ constructor(options) { // generated configuration options @@ -95,6 +74,8 @@ class SWPrecacheWebpackPlugin { this.options = _extends({}, DEFAULT_OPTIONS, options); // generated configuration that will override user options this.overrides = {}; + // push warning messages here + this.warnings = []; } /** @@ -105,92 +86,173 @@ class SWPrecacheWebpackPlugin { } apply(compiler) { + // sw-precache needs physical files to reference so we MUST wait until after assets are emitted before generating the service-worker. compiler.plugin("after-emit", (compilation, callback) => { - // get the defaults from options - var _options = this.options; - const importScripts = _options.importScripts, - staticFileGlobsIgnorePatterns = _options.staticFileGlobsIgnorePatterns, - mergeStaticsConfig = _options.mergeStaticsConfig; + this.configure(compiler, compilation); // configure the serviceworker options + this.checkWarnings(compilation); + + // generate service worker then write to file system + this.createServiceWorker() + .then(serviceWorker => this.writeServiceWorker(serviceWorker, compiler)) + .then(() => callback()) + .catch(err => callback(err)); + }); + } - // get the output path specified in webpack config + configure(compiler, compilation) { + // get the defaults from options + var _options = this.options; + const importScripts = _options.importScripts, + staticFileGlobsIgnorePatterns = _options.staticFileGlobsIgnorePatterns, + mergeStaticsConfig = _options.mergeStaticsConfig; + var _options$stripPrefixM = _options.stripPrefixMulti; + const stripPrefixMulti = _options$stripPrefixM === undefined ? {} : _options$stripPrefixM; - const outputPath = compiler.options.output.path || DEFAULT_OUTPUT_PATH; + // get the output path used by webpack - // get the public path specified in webpack config - var _compiler$options$out = compiler.options.output.publicPath; - const publicPath = _compiler$options$out === undefined - ? DEFAULT_PUBLIC_PATH - : _compiler$options$out; + const outputPath = compiler.outputPath; - if (this.options.filepath && !this.options.noWarning) { - // warn about changing filepath - compilation.warnings.push(new Error(FILEPATH_WARNING)); - } + // outputPath + filename or the user option - // get all assets outputted by webpack - const assetGlobs = Object.keys(compilation.assets).map(f => - _path2.default.join(outputPath, f) - ); + var _options$filepath = this.options.filepath; + const filepath = + _options$filepath === undefined + ? _path2.default.resolve(outputPath, this.options.filename) + : _options$filepath; - // merge assetGlobs with provided staticFileGlobs and filter using staticFileGlobsIgnorePatterns - const staticFileGlobs = assetGlobs - .concat(this.options.staticFileGlobs || []) - .filter(text => !staticFileGlobsIgnorePatterns.some(regex => regex.test(text))); + // get the public path specified in webpack config - const stripPrefixMulti = _extends({}, this.options.stripPrefixMulti); + var _compiler$options$out = compiler.options.output.publicPath; + const publicPath = + _compiler$options$out === undefined ? DEFAULT_PUBLIC_PATH : _compiler$options$out; - if (outputPath) { - // strip the webpack config's output.path - stripPrefixMulti[`${outputPath}${_path2.default.sep}`] = publicPath; - } + // get all assets outputted by webpack + + const assetGlobs = Object.keys(compilation.assets).map(f => _path2.default.join(outputPath, f)); - this.config = _extends({}, this.config, { + // merge assetGlobs with provided staticFileGlobs and filter using staticFileGlobsIgnorePatterns + const staticFileGlobs = assetGlobs + .concat(this.options.staticFileGlobs || []) + .filter(text => !staticFileGlobsIgnorePatterns.some(regex => regex.test(text))); + + if (outputPath) { + // strip the webpack config's output.path (replace for windows users) + stripPrefixMulti[`${outputPath}${_path2.default.sep}`.replace(/\\/g, "/")] = publicPath; + } + + this.config = _extends({}, this.config, { + staticFileGlobs: staticFileGlobs, + stripPrefixMulti: stripPrefixMulti + }); + + // set the actual filepath + this.overrides.filepath = filepath; + + // resolve [hash] used in importScripts + this.configureImportScripts(importScripts, publicPath, compiler, compilation); + + if (mergeStaticsConfig) { + // merge generated and user provided options + this.overrides = _extends({}, this.overrides, { staticFileGlobs: staticFileGlobs, stripPrefixMulti: stripPrefixMulti }); + } + } + + configureImportScripts(importScripts, publicPath, compiler, compilation) { + if (!importScripts) { + return; + } + + var _compilation$getStats = compilation.getStats().toJson({ hash: true, chunks: true }); - if (importScripts) { - this.overrides.importScripts = importScripts - .map(f => f.replace(/\[hash\]/g, compilation.hash)) // need to override importScripts with stats.hash - .map(f => _url2.default.resolve(publicPath, f)); // add publicPath to importScripts + const hash = _compilation$getStats.hash, + chunks = _compilation$getStats.chunks; + + this.overrides.importScripts = importScripts.reduce((fileList, criteria) => { + // legacy support for importScripts items defined as string + if (typeof criteria === "string") { + criteria = { filename: criteria }; } - if (mergeStaticsConfig) { - // merge generated and user provided options - this.overrides = _extends({}, this.overrides, { - staticFileGlobs: staticFileGlobs, - stripPrefixMulti: stripPrefixMulti - }); + const hasFileName = !!criteria.filename; + const hasChunkName = !!criteria.chunkName; + + if (hasFileName && hasChunkName) { + this.warnings.push( + new Error((0, _util.format)(CHUNK_NAME_OVERRIDES_FILENAME_WARNING, criteria)) + ); } - const done = () => callback(); - const error = err => callback(err); + if (hasChunkName) { + const chunk = chunks.find(c => c.names.includes(criteria.chunkName)); - this.writeServiceWorker(compiler).then(done, error); + if (!chunk) { + compilation.errors.push( + new Error((0, _util.format)(CHUNK_NAME_NOT_FOUND_ERROR, criteria.chunkName)) + ); + return fileList; + } + + const chunkFileName = chunk.files[chunk.names.indexOf(criteria.chunkName)]; + fileList.push(_url2.default.resolve(publicPath, chunkFileName)); + } else if (hasFileName) { + const hashedFilename = criteria.filename.replace(/\[hash\]/g, hash); + fileList.push(_url2.default.resolve(publicPath, hashedFilename)); + } + + return fileList; + }, []); + } + + createServiceWorker() { + return _swPrecache2.default.generate(this.workerOptions).then(serviceWorkerFileContents => { + if (this.options.minify) { + const uglifyFiles = {}; + uglifyFiles[this.options.filename] = serviceWorkerFileContents; + return _uglifyJs2.default.minify(uglifyFiles).code; + } + return serviceWorkerFileContents; }); } - writeServiceWorker(compiler) { - const fileDir = compiler.options.output.path || DEFAULT_OUTPUT_PATH; - var _options$filepath = this.options.filepath; - const filepath = _options$filepath === undefined - ? _path2.default.join(fileDir, this.options.filename) - : _options$filepath; - - return (0, _del2.default)(filepath, { force: this.options.forceDelete }) - .then(() => _swPrecache2.default.generate(this.workerOptions)) - .then(serviceWorkerFileContents => { - if (this.options.minify) { - const uglifyFiles = {}; - uglifyFiles[this.options.filename] = serviceWorkerFileContents; - const minifedCodeObj = _uglifyJs2.default.minify(uglifyFiles, { fromString: true }); - return minifedCodeObj.code; - } - return serviceWorkerFileContents; - }) - .then(possiblyMinifiedServiceWorkerFileContents => - _fs2.default.writeFileSync(filepath, possiblyMinifiedServiceWorkerFileContents) - ); + writeServiceWorker(serviceWorker, compiler) { + const filepath = this.workerOptions.filepath; + var _compiler$outputFileS = compiler.outputFileSystem; + const mkdirp = _compiler$outputFileS.mkdirp, + writeFile = _compiler$outputFileS.writeFile; + + // use the outputFileSystem api to manually write service workers rather than adding to the compilation assets + + return new Promise(resolve => { + mkdirp(_path2.default.resolve(filepath, ".."), () => { + writeFile(filepath, serviceWorker, resolve); + }); + }); + } + + /** + * Push plugin warnings to webpack log + * @param {object} compilation - webpack compilation + * @returns {void} + */ + checkWarnings(compilation) { + if (!this.options.noWarnings) { + if (this.options.filepath) { + // warn about changing filepath + this.warnings.push(new Error(FILEPATH_WARNING)); + } + + if (this.options.forceDelete) { + // deprecate forceDelete + this.warnings.push(new Error(FORCEDELETE_WARNING)); + } + } + + if (this.workerOptions.debug) { + this.warnings.forEach(warning => compilation.warnings.push(warning)); + } } } diff --git a/packages/electrode-archetype-react-app-dev/package.json b/packages/electrode-archetype-react-app-dev/package.json index a0c085abb..274a60a6c 100644 --- a/packages/electrode-archetype-react-app-dev/package.json +++ b/packages/electrode-archetype-react-app-dev/package.json @@ -45,7 +45,6 @@ "convert-source-map": "^1.5.0", "css-loader": "^0.26.1", "css-split-webpack-plugin": "^0.2.0", - "del": "^2.2.2", "electrode-bundle-analyzer": "^1.0.0", "electrode-cdn-file-loader": "^1.0.0", "electrode-electrify": "^1.0.0", @@ -106,6 +105,7 @@ "stylus-relative-loader": "^3.0.0", "sw-precache": "^5.0.0", "sw-toolbox": "^3.4.0", + "uglify-js": "^3.0.26", "url-loader": "^0.5.6", "web-app-manifest-loader": "^0.1.1", "webpack": "^2.2.0", diff --git a/packages/electrode-archetype-react-component-dev/config/karma/browser-settings.js b/packages/electrode-archetype-react-component-dev/config/karma/browser-settings.js new file mode 100644 index 000000000..3277bc51f --- /dev/null +++ b/packages/electrode-archetype-react-component-dev/config/karma/browser-settings.js @@ -0,0 +1,25 @@ +"use strict"; + +const archetype = require("electrode-archetype-react-component/config/archetype"); +const browser = archetype.karma.browser.toLowerCase(); + +module.exports = function(settings) { + if (browser === "chrome") { + settings.browsers = ["ChromeHeadless"]; + settings.frameworks = ["mocha"]; + settings.crossOriginAttribute = false; + console.log("Using Chrome Headless to run Karma test"); + + } else if (browser === "phantomjs") { + settings.browsers = ["PhantomJS"]; + settings.frameworks = ["mocha", "phantomjs-shim"]; + console.warn( + "Using PhantomJS to run Karma test. It's been deprecated and may be removed in the future." + ); + } else { + console.error(`Unknown browser ${browser} set for Karma test. Failed.`); + return process.exit(1); + } + + return settings; +}; diff --git a/packages/electrode-archetype-react-component-dev/config/karma/karma.conf.dev.js b/packages/electrode-archetype-react-component-dev/config/karma/karma.conf.dev.js index 8df909f58..b4b5c8fcc 100644 --- a/packages/electrode-archetype-react-component-dev/config/karma/karma.conf.dev.js +++ b/packages/electrode-archetype-react-component-dev/config/karma/karma.conf.dev.js @@ -1,5 +1,7 @@ "use strict"; +const browserSettings = require("./browser-settings"); + /* * Karma Configuration: "dev" version. * @@ -9,10 +11,8 @@ * server during the test run. */ module.exports = function (config) { - config.set({ - frameworks: ["mocha", "phantomjs-shim"], + const base = { reporters: ["spec"], - browsers: ["PhantomJS"], basePath: process.cwd(), // repository root. files: [ // Test bundle (must be created via `npm run dev|hot|server-test`) @@ -26,5 +26,9 @@ module.exports = function (config) { ui: "bdd" } } - }); + }; + + browserSettings(base); + + config.set(base); }; diff --git a/packages/electrode-archetype-react-component-dev/config/karma/karma.conf.js b/packages/electrode-archetype-react-component-dev/config/karma/karma.conf.js index 718d83117..9f942292a 100644 --- a/packages/electrode-archetype-react-component-dev/config/karma/karma.conf.js +++ b/packages/electrode-archetype-react-component-dev/config/karma/karma.conf.js @@ -4,19 +4,20 @@ var path = require("path"); var webpackCfg = require("../webpack/webpack.config.test"); -var MAIN_PATH = require.resolve("electrode-archetype-react-component-dev/config/karma/entry.js"); // eslint-disable-line max-len +var MAIN_PATH = require.resolve( + "electrode-archetype-react-component-dev/config/karma/entry.js" +); + +const browserSettings = require("./browser-settings"); var PREPROCESSORS = {}; PREPROCESSORS[MAIN_PATH] = ["webpack"]; -module.exports = function (config) { - config.set({ +module.exports = function(config) { + const base = { basePath: process.cwd(), - frameworks: ["mocha", "phantomjs-shim", "intl-shim"], - files: [ - MAIN_PATH - ], + files: [MAIN_PATH], preprocessors: PREPROCESSORS, webpack: webpackCfg, webpackServer: { @@ -38,7 +39,6 @@ module.exports = function (config) { logLevel: config.LOG_INFO, colors: true, autoWatch: false, - browsers: ["PhantomJS"], reporters: ["spec", "coverage"], browserNoActivityTimeout: 60000, coverageReporter: { @@ -51,5 +51,9 @@ module.exports = function (config) { }, captureTimeout: 100000, singleRun: true - }); + }; + + browserSettings(base); + + config.set(base); }; diff --git a/packages/electrode-archetype-react-component-dev/package.json b/packages/electrode-archetype-react-component-dev/package.json index ac6ff155d..098f7dfff 100644 --- a/packages/electrode-archetype-react-component-dev/package.json +++ b/packages/electrode-archetype-react-component-dev/package.json @@ -53,7 +53,7 @@ "json-loader": "^0.5.3", "jsonfile": "^2.2.2", "karma": "^1.5.0", - "karma-chrome-launcher": "^0.2.0", + "karma-chrome-launcher": "^2.1.1", "karma-coverage": "^1.1.1", "karma-firefox-launcher": "^1", "karma-ie-launcher": "^1.0.0", diff --git a/packages/electrode-archetype-react-component/config/archetype.js b/packages/electrode-archetype-react-component/config/archetype.js index 54b0e64f1..005dadc5a 100644 --- a/packages/electrode-archetype-react-component/config/archetype.js +++ b/packages/electrode-archetype-react-component/config/archetype.js @@ -13,5 +13,8 @@ module.exports = { devPort: 2992, testPort: 3001, modulesDirectories: ["node_modules"] + }, + karma: { + browser: process.env.KARMA_BROWSER === undefined ? "chrome" : process.env.KARMA_BROWSER } }; diff --git a/packages/electrode-cookies/.eslintignore b/packages/electrode-cookies/.eslintignore new file mode 100644 index 000000000..62562b74a --- /dev/null +++ b/packages/electrode-cookies/.eslintignore @@ -0,0 +1,2 @@ +coverage +node_modules diff --git a/packages/electrode-cookies/.eslintrc b/packages/electrode-cookies/.eslintrc new file mode 100644 index 000000000..5f6ea6525 --- /dev/null +++ b/packages/electrode-cookies/.eslintrc @@ -0,0 +1,3 @@ +--- +extends: + - "./node_modules/electrode-archetype-njs-module-dev/config/eslint/.eslintrc-node" diff --git a/packages/electrode-cookies/.npmignore b/packages/electrode-cookies/.npmignore new file mode 100644 index 000000000..125cf2bca --- /dev/null +++ b/packages/electrode-cookies/.npmignore @@ -0,0 +1,226 @@ + +# Created by https://www.gitignore.io/api/osx,node + +### 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 + + +### 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 + +### Vim template +[._]*.s[a-w][a-z] +[._]s[a-w][a-z] +*.un~ +Session.vim +.netrwhist +*~ +### GitBook template +# 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 +### TortoiseGit template +# Project-level settings +/.tgitconfig +### Xcode template +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## Build generated +build/ +DerivedData + +## Various settings +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata + +## Other +*.xccheckout +*.moved-aside +*.xcuserstate +### Emacs template +# -*- mode: gitignore; -*- +*~ +\#*\# +/.emacs.desktop +/.emacs.desktop.lock +*.elc +auto-save-list +tramp +.\#* + +# Org-mode +.org-id-locations +*_archive + +# flymake-mode +*_flymake.* + +# eshell files +/eshell/history +/eshell/lastdir + +# elpa packages +/elpa/ + +# reftex files +*.rel + +# AUCTeX auto folder +/auto/ + +# cask packages +.cask/ +### OSX template +.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 +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio + +*.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 + +# 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 +/clap.js +/.npmignore +.nyc_output diff --git a/packages/electrode-cookies/README.md b/packages/electrode-cookies/README.md new file mode 100644 index 000000000..c3d2b2225 --- /dev/null +++ b/packages/electrode-cookies/README.md @@ -0,0 +1,112 @@ +# Electrode Cookies + +Electrode isomorphic cookies lib. + +## Install + + npm install electrode-cookies --save + +## Usage + +This module offers reading and setting cookies in React code that works in both the browser or when doing Server Side Rendering. + +In your pure server only code, you can also use this module to read and set cookies, but you **MUST** pass the `request` object in the options. Otherwise an assert error will be thrown. + +In NodeJS land: + +```js +const Cookies = require("electrode-cookies"); +``` + +### Reading cookies + +In ReactJS land: + +```js +import Cookies from "electrode-cookies"; +const value = Cookies.get("test-cookie"); +``` + +In NodeJS land: + +> Note the difference is that `request` is passed in options. + +```js +const Cookies = require("electrode-cookies"); +const value = Cookies.get("test-cookie", { request }); +``` + +### Writing cookies + +In ReactJS land: + +```js +import Cookies from "electrode-cookies"; +Cookies.set( "foo", "bar", { path: "/", domain: ".walmart.com" } ); +``` + +In NodeJS land: + +> Note the difference is that `request` is passed in options. + +```js +const Cookies = require("electrode-cookies"); +Cookies.set( "foo", "bar", { request, path: "/", domain: ".walmart.com" } ); +``` + +## Electrode Server Setup + +The cookie writing on server side requires support from a Hapi plugin. If you use [electrode-server], then it should have setup the plugin for you by default. Otherwise, you need to register the [hapi plugin](hapi-plugin.js). + +## APIs + +### [Cookies.get](#cookiesget) + +`Cookies.get(key, [options])` + +Parameters: + +- `key` - name of the cookie +- `options` - (optional) **_Available for Server side only._** options for getting the cookie + - `request` - The server `request` object (**Required on server**). + - `matchSubStr` - If `true`, then do substring matching of key with all cookie keys. + - `skipEncoding` - (applies only if `matchSubStr` is `true`) If `true`, then do not encode the key or decode the value. + +Returns the value of the cookie for `key`. + +### [Cookies.set](#cookiesset) + +`Cookies.set(key, value, [options])` + +Set a cookie with `key` and `value`. + +Parameters: + +- `key` - name of the cookie +- `value` - value of the cookie +- `options` - (optional) options for the cookie + - `request` - On the server side, the `request` object (**Required on server**). + - `path` - string path of the cookie **_Default:_** `"/"` + - `domain` - string domain of the cookie + - `expires` - number of seconds the cookie will expire + - `secure` - A boolean of whether or not the cookie should only be available over SSL **_Default:_** false + - `httpOnly` - A boolean of whether or not the cookie should only be available over HTTP(S) **_Default:_** false + - `forceAuthEncoding` - Forces non-standard encoding for `+` and `/` characters, use with auth cookies. + - `skipEncoding` - Skip encoding/escaping of the cookie value. See [source](https://gecgithub01.walmart.com/electrode/electrode-cookies/blob/master/lib/index.js) for details. + +### [Cookies.expire](#cookiesexpire) + +`Cookies.expire(key, [options])` + +Expires a cookie specified by `key`. + +Parameters: + +- `key` - name of the cookie +- `options` - (optional) options for the cookie + - `path` - string path of the cookie **_Default:_** `"/"` + - `domain` - string domain of the cookie + - `secure` - A boolean of whether or not the cookie should only be available over SSL **_Default:_** false + - `request` - The server request object (**Required on server**) + +[electrode-server]: https://gecgithub01.walmart.com/electrode/electrode-server diff --git a/packages/electrode-cookies/cookies-js/index.js b/packages/electrode-cookies/cookies-js/index.js new file mode 100644 index 000000000..60b3603bc --- /dev/null +++ b/packages/electrode-cookies/cookies-js/index.js @@ -0,0 +1,179 @@ +/* + * Cookies.js - 1.2.2 + * https://github.com/ScottHamper/Cookies + * + * This is free and unencumbered software released into the public domain. + */ +(function(global, undefined) { + "use strict"; + + var factory = function(window) { + if (typeof window.document !== "object") { + throw new Error("Cookies.js requires a `window` with a `document` object"); + } + + var Cookies = function(key, value, options) { + return arguments.length === 1 ? Cookies.get(key) : Cookies.set(key, value, options); + }; + + // Allows for setter injection in unit tests + Cookies._document = window.document; + + // Used to ensure cookie keys do not collide with + // built-in `Object` properties + Cookies._cacheKeyPrefix = "cookey."; // Hurr hurr, :) + + Cookies._maxExpireDate = new Date("Fri, 31 Dec 9999 23:59:59 UTC"); + + Cookies.defaults = { + path: "/", + secure: false + }; + + Cookies.get = function(key) { + if (Cookies._cachedDocumentCookie !== Cookies._document.cookie) { + Cookies._renewCache(); + } + + var value = Cookies._cache[Cookies._cacheKeyPrefix + key]; + + return value === undefined ? undefined : decodeURIComponent(value); + }; + + Cookies.set = function(key, value, options) { + options = Cookies._getExtendedOptions(options); + options.expires = Cookies._getExpiresDate(value === undefined ? -1 : options.expires); + + Cookies._document.cookie = Cookies._generateCookieString(key, value, options); + + return Cookies; + }; + + Cookies.expire = function(key, options) { + return Cookies.set(key, undefined, options); + }; + + Cookies._getExtendedOptions = function(options) { + return { + path: (options && options.path) || Cookies.defaults.path, + domain: (options && options.domain) || Cookies.defaults.domain, + expires: (options && options.expires) || Cookies.defaults.expires, + secure: options && options.secure !== undefined ? options.secure : Cookies.defaults.secure, + skipEncoding: options && options.skipEncoding + }; + }; + + Cookies._isValidDate = function(date) { + return Object.prototype.toString.call(date) === "[object Date]" && !isNaN(date.getTime()); + }; + + Cookies._getExpiresDate = function(expires, now) { + now = now || new Date(); + + if (typeof expires === "number") { + expires = + expires === Infinity ? Cookies._maxExpireDate : new Date(now.getTime() + expires * 1000); + } else if (typeof expires === "string") { + expires = new Date(expires); + } + + if (expires && !Cookies._isValidDate(expires)) { + throw new Error("`expires` parameter cannot be converted to a valid Date instance"); + } + + return expires; + }; + + Cookies._generateCookieString = function(key, value, options) { + options = options || {}; + + if (!options.skipEncoding) { + key = key.replace(/[^#$&+\^`|]/g, encodeURIComponent); + key = key.replace(/\(/g, "%28").replace(/\)/g, "%29"); + // \--9 being exclude chars - to 9, with 9 being before : + value = (value + "").replace(/[^!#&-+\--9<-\[\]-~]/g, encodeURIComponent); + } + + var cookieString = key + "=" + value; + cookieString += options.path ? ";path=" + options.path : ""; + cookieString += options.domain ? ";domain=" + options.domain : ""; + cookieString += options.expires ? ";expires=" + options.expires.toUTCString() : ""; + cookieString += options.secure ? ";secure" : ""; + + return cookieString; + }; + + Cookies._getCacheFromString = function(documentCookie) { + var cookieCache = {}; + var cookiesArray = documentCookie ? documentCookie.split("; ") : []; + + for (var i = 0; i < cookiesArray.length; i++) { + var cookieKvp = Cookies._getKeyValuePairFromCookieString(cookiesArray[i]); + + if (cookieCache[Cookies._cacheKeyPrefix + cookieKvp.key] === undefined) { + cookieCache[Cookies._cacheKeyPrefix + cookieKvp.key] = cookieKvp.value; + } + } + + return cookieCache; + }; + + Cookies._getKeyValuePairFromCookieString = function(cookieString) { + // "=" is a valid character in a cookie value according to RFC6265, so cannot `split('=')` + var separatorIndex = cookieString.indexOf("="); + + // IE omits the "=" when the cookie value is an empty string + separatorIndex = separatorIndex < 0 ? cookieString.length : separatorIndex; + + var key = cookieString.substr(0, separatorIndex); + var decodedKey; + try { + decodedKey = decodeURIComponent(key); + } catch (e) { + if (console && typeof console.error === "function") { + console.error('Could not decode cookie with key "' + key + '"', e); + } + } + + return { + key: decodedKey, + value: cookieString.substr(separatorIndex + 1) // Defer decoding value until accessed + }; + }; + + Cookies._renewCache = function() { + Cookies._cache = Cookies._getCacheFromString(Cookies._document.cookie); + Cookies._cachedDocumentCookie = Cookies._document.cookie; + }; + + Cookies._areEnabled = function() { + var testKey = "cookies.js"; + var areEnabled = Cookies.set(testKey, 1).get(testKey) === "1"; + Cookies.expire(testKey); + return areEnabled; + }; + + Cookies.enabled = Cookies._areEnabled(); + + return Cookies; + }; + + var cookiesExport = typeof global.document === "object" ? factory(global) : factory; + + // AMD support + if (typeof define === "function" && define.amd) { + define(function() { + return cookiesExport; + }); + // CommonJS/Node.js support + } else if (typeof exports === "object") { + // Support Node.js specific `module.exports` (which can be a function) + if (typeof module === "object" && typeof module.exports === "object") { + exports = module.exports = cookiesExport; + } + // But always support CommonJS module 1.1.1 spec (`exports` cannot be a function) + exports.Cookies = cookiesExport; + } else { + global.Cookies = cookiesExport; + } +})(typeof window === "undefined" ? this : window); diff --git a/packages/electrode-cookies/hapi-plugin.js b/packages/electrode-cookies/hapi-plugin.js new file mode 100644 index 000000000..49a13cb05 --- /dev/null +++ b/packages/electrode-cookies/hapi-plugin.js @@ -0,0 +1,25 @@ +"use strict"; + +var each = require("lodash/each"); + +function electrodeCookiesRegister(server, options, next) { + server.ext("onPreResponse", (request, reply) => { + if (request.app.replyStates) { + each(request.app.replyStates, (state, name) => { + reply.state(name, state.value, state.options); + }); + } + + return reply.continue(); + }); + + next(); +} + +electrodeCookiesRegister.attributes = { + pkg: require("./package.json") +}; + +module.exports = { + register: electrodeCookiesRegister +}; diff --git a/packages/electrode-cookies/lib/csindex.js b/packages/electrode-cookies/lib/csindex.js new file mode 100644 index 000000000..28da4c64e --- /dev/null +++ b/packages/electrode-cookies/lib/csindex.js @@ -0,0 +1,8 @@ +/* eslint-disable */ +var cookies = require("../cookies-js"); + +module.exports = { + get: cookies.get, + set: cookies.set, + expire: cookies.expire +}; diff --git a/packages/electrode-cookies/lib/index.js b/packages/electrode-cookies/lib/index.js new file mode 100644 index 000000000..247c58400 --- /dev/null +++ b/packages/electrode-cookies/lib/index.js @@ -0,0 +1,91 @@ +"use strict"; + +const safeGet = require("lodash/get"); +const reduce = require("lodash/reduce"); +const assert = require("assert"); + +const replacers = { "(": "%28", ")": "%29" }; + +const encodeKey = key => + key.replace(/[^#$&+\^`|]/g, encodeURIComponent).replace(/[\(\)]/g, m => replacers[m]); + +const cookies = { + get: (key, options) => { + options = options || {}; + assert(options.request, "The request option is not set"); + + if (options.matchSubStr) { + const substring = options.skipEncoding === true ? key : encodeKey(key); + + const NOT_FOUND = -1; + + try { + return reduce( + options.request.state, + (result, value, k) => { + if (k.indexOf(substring) > NOT_FOUND) { + result[k] = + options.skipEncoding === true || value === undefined + ? value + : decodeURIComponent(value); + } + return result; + }, + {} + ); + } catch (err) { + return null; + } + } + + try { + const value = options.request.state[encodeKey(key)]; + return value === undefined ? undefined : decodeURIComponent(value); + } catch (err) { + return null; + } + }, + + set: (key, value, options) => { + options = options || {}; + assert(options.request, "The request option is not set"); + + const MSEC = 1000; + + const setOptions = { + path: options.path || "/", + ttl: options.expires && options.expires * MSEC, + isHttpOnly: options.httpOnly, + isSecure: options.secure, + domain: options.domain, + strictHeader: safeGet(options, "strictHeader", true) + }; + + const request = options.request; + + if (!request.app.replyStates) { + request.app.replyStates = {}; + } + + if (options.skipEncoding !== true) { + key = encodeKey(key); + value = (typeof value === "string" ? value : JSON.stringify(value)) + // \--9 being exclude chars - to 9, with 9 being before : + .replace(/[^!#&-+\--9<-\[\]-~]/g, encodeURIComponent); + } + + if (options.forceAuthEncoding) { + value = value.replace(/[+/]/g, encodeURIComponent); + } + + request.app.replyStates[key] = { value, options: setOptions }; + }, + + expire: (key, options) => { + options = options || {}; + options.expires = 0; + cookies.set(key, "x", options); + } +}; + +module.exports = cookies; diff --git a/packages/electrode-cookies/package.json b/packages/electrode-cookies/package.json new file mode 100644 index 000000000..6df793e85 --- /dev/null +++ b/packages/electrode-cookies/package.json @@ -0,0 +1,48 @@ +{ + "name": "electrode-cookies", + "version": "2.0.0", + "description": "Electrode ISO cookies lib", + "main": "lib/index.js", + "browser": "lib/csindex.js", + "scripts": { + "test": "clap check", + "format": "prettier --write --print-width 100 *.js `find . -type d -d 1 -exec echo '{}/**/*.js' \\; | egrep -v '(/node_modules/|/dist/|/coverage/)'`" + }, + "repository": { + "type": "git", + "url": "https://github.com/electrode-io/electrode.git" + }, + "keywords": [], + "license": "Apache-2.0", + "dependencies": { + "lodash": "^4.14.1" + }, + "devDependencies": { + "bluebird": "^2.10.2", + "electrode-archetype-njs-module-dev": "^2.2.0", + "electrode-server": "^1.2.2", + "eslint-config-prettier": "^2.3.0", + "jsdom": "^9.4.2", + "jsdom-global": "^2.0.0", + "mock-require": "^1.3.0", + "prettier": "^1.5.3", + "set-cookie-parser": "^1.0.1", + "superagent": "^1.7.2", + "xclap": "^0.2.0" + }, + "nyc": { + "all": true, + "reporter": [ + "lcov", + "text", + "text-summary" + ], + "exclude": [ + "coverage", + "*clap.js", + "gulpfile.js", + "dist", + "test" + ] + } +} diff --git a/packages/electrode-cookies/test/.eslintrc b/packages/electrode-cookies/test/.eslintrc new file mode 100644 index 000000000..e9da91c41 --- /dev/null +++ b/packages/electrode-cookies/test/.eslintrc @@ -0,0 +1,3 @@ +--- +extends: + - "../node_modules/electrode-archetype-njs-module-dev/config/eslint/.eslintrc-test" \ No newline at end of file diff --git a/packages/electrode-cookies/test/mocha.opts b/packages/electrode-cookies/test/mocha.opts new file mode 100644 index 000000000..022f99b50 --- /dev/null +++ b/packages/electrode-cookies/test/mocha.opts @@ -0,0 +1,2 @@ +--require node_modules/electrode-archetype-njs-module-dev/config/test/setup.js +--recursive diff --git a/packages/electrode-cookies/test/spec/csindex.spec.js b/packages/electrode-cookies/test/spec/csindex.spec.js new file mode 100644 index 000000000..5aeaf167c --- /dev/null +++ b/packages/electrode-cookies/test/spec/csindex.spec.js @@ -0,0 +1,43 @@ +"use strict"; + +const cleanup = require("jsdom-global")(); +const mockReq = require("mock-require"); +const expect = require("chai").expect; + +describe("csindex", () => { + let csIndex; + beforeEach(() => { + mockReq.reRequire("../../cookies-js"); + mockReq.reRequire("../../lib/csindex"); + csIndex = require("../../lib/csindex"); + }); + + after(() => { + cleanup(); + }); + + it(".set should encode value", function() { + const document = global.document; + const key = "($;enc:)"; + const value = "i$xx:x;"; + csIndex.set(key, value); + let cookie = document.cookie; + expect(cookie).includes("%28$%3Benc%3A%29=i%24xx%3Ax%3B"); + const verifyValue = csIndex.get(key); + expect(verifyValue).to.equal(value); + csIndex.expire(key); + cookie = document.cookie; + expect(cookie).to.be.empty; + }); + + it(".set should honor skipEncoding option", function() { + const document = global.document; + const key = "!enc:)"; + const value = "i$xx:x"; + csIndex.set(key, value, { skipEncoding: true }); + const cookie = document.cookie; + expect(cookie).to.equal(`${key}=${value}`); + const verifyValue = csIndex.get(key); + expect(verifyValue).to.equal(value); + }); +}); diff --git a/packages/electrode-cookies/test/spec/hapi-plugin.spec.js b/packages/electrode-cookies/test/spec/hapi-plugin.spec.js new file mode 100644 index 000000000..12afc8806 --- /dev/null +++ b/packages/electrode-cookies/test/spec/hapi-plugin.spec.js @@ -0,0 +1,20 @@ +"use strict"; + +const expect = require("chai").expect; +const cookiesPlugin = require("../../hapi-plugin"); + +describe("Hapi plugin", () => { + it("should handle request.app.replyStates not being set", done => { + const request = { app: {} }; + const reply = { continue: () => {} }; + const server = { + ext: (event, handler) => { + expect(event).to.equal("onPreResponse"); + handler(request, reply); + done(); + } + }; + + cookiesPlugin.register(server, {}, () => {}); + }); +}); diff --git a/packages/electrode-cookies/test/spec/index.spec.js b/packages/electrode-cookies/test/spec/index.spec.js new file mode 100644 index 000000000..bc7f0b7d1 --- /dev/null +++ b/packages/electrode-cookies/test/spec/index.spec.js @@ -0,0 +1,314 @@ +"use strict"; + +/* eslint-disable quotes, max-len */ + +const electrodeServer = require("electrode-server"); +const superAgent = require("superagent"); +const Cookies = require("../../"); +const CookieParser = require("set-cookie-parser"); +const _ = require("lodash"); +const expect = require("chai").expect; + +function makeConfig() { + return { + services: { + autoInit: false, + autoDiscovery: false + }, + plugins: { + "electrode-cookies/hapi-plugin": { + register: require("../../hapi-plugin.js") + } + } + }; +} + +describe("cookies", function() { + this.timeout(5000); + + let currentServer; + + const startServer = config => { + return electrodeServer(config).tap(server => { + currentServer = server; + }); + }; + + afterEach(done => { + if (currentServer) { + currentServer.stop(err => { + currentServer = undefined; + done(err); + }); + } else { + done(); + } + }); + + it("should set cookie", () => { + const handler = (request, reply) => { + Cookies.set("test", "bar", { + path: "/", + expires: 0, + secure: true, + domain: ".walmart.com", + httpOnly: false, + request + }); + Cookies.set("test2", "bar2", { + path: "/test", + expires: 1000, + secure: false, + domain: "x.walmart.com", + httpOnly: true, + request + }); + Cookies.set("test3", "bar3", { + expires: 50000, + secure: false, + request + }); + Cookies.set("test4", "bar4", { request }); + Cookies.expire("test5", { request }); + Cookies.set("($;enc:)", "(i$xx:x;)", { + expires: 50000, + secure: false, + request + }); + Cookies.set("----", { test: "12345", flag: true }, { request }); + const test1 = Cookies.get("test1", { request }); + Cookies.set("affiliate", "reflectorid=123:wmlspartner=wmlspartnerID:lastupd=9876630", { + strictHeader: false, + skipEncoding: true, + request + }); + Cookies.set("reflector", `"reflectorid=9834123:lastupd=98765:firstcreate=87654"`, { + strictHeader: false, + skipEncoding: true, + request + }); + Cookies.set("($!enc:)", "(i$xx:x)", { + expires: 50000, + secure: false, + strictHeader: false, + skipEncoding: true, + request + }); + Cookies.set("plusforwardslash", "+/", { + strictHeader: false, + forceAuthEncoding: true, + request + }); + reply({ test1, now: Date.now() }); + }; + + const serverConfig = makeConfig(); + + return startServer(serverConfig) + .then(server => { + server.route({ + method: "get", + path: "/test", + handler + }); + + return new Promise((resolve, reject) => { + superAgent("http://localhost:3000/test") + .set("cookie", "test1=hello") + .end((err, response) => { + return err ? reject(err) : resolve(response); + }); + }); + }) + .then(response => { + expect(response.body).to.have.keys(["test1", "now"]); + expect(response.headers["set-cookie"]).to.be.an("array").with.length(11); + + const cookies = CookieParser.parse(response.headers["set-cookie"]); + + const verifyCookie = data => { + const c = _.find(cookies, x => x.name === data.name); + expect(c, `No cookie found with name "${data.name}"`).to.exist; + if (data.hasOwnProperty("maxAge")) { + const expires = data.maxAge > 0 ? response.body.now + data.maxAge * 1000 : 0; + expect(c.expires.toGMTString()).to.equal(new Date(expires).toGMTString()); + delete c.expires; + } + expect( + _.pick(c, "name", "value", "path", "maxAge", "domain", "secure", "httpOnly") + ).to.deep.equal(data); + }; + + verifyCookie({ + name: "test", + value: "bar", + path: "/", + maxAge: 0, + domain: ".walmart.com", + secure: true + }); + verifyCookie({ + name: "test2", + value: "bar2", + path: "/test", + maxAge: 1000, + domain: "x.walmart.com", + httpOnly: true + }); + verifyCookie({ + name: "test3", + value: "bar3", + path: "/", + maxAge: 50000 + }); + verifyCookie({ + name: "test4", + value: "bar4", + path: "/" + }); + verifyCookie({ + name: "test5", + value: "x", + path: "/", + maxAge: 0 + }); + verifyCookie({ + name: "%28$%3Benc%3A%29", + value: "(i%24xx%3Ax%3B)", + path: "/", + maxAge: 50000 + }); + verifyCookie({ + name: "----", + value: "{%22test%22%3A%2212345%22%2C%22flag%22%3Atrue}", + path: "/" + }); + verifyCookie({ + name: "affiliate", + value: "reflectorid=123:wmlspartner=wmlspartnerID:lastupd=9876630", + path: "/" + }); + verifyCookie({ + name: "reflector", + value: `"reflectorid=9834123:lastupd=98765:firstcreate=87654"`, + path: "/" + }); + verifyCookie({ + name: "($!enc:)", + value: "(i$xx:x)", + path: "/", + maxAge: 50000 + }); + verifyCookie({ + name: "plusforwardslash", + value: "%2B%2F", + path: "/" + }); + + expect(response.body.test1).to.equal("hello"); + }); + }); + + it("should get cookie", () => { + const handler = (request, reply) => { + try { + expect(Cookies.get("test", { request })).to.equal("bar"); + expect(Cookies.get("test2", { request })).to.equal("bar2"); + expect(Cookies.get("test3", { request })).to.equal("bar3"); + expect(Cookies.get("test4", { request })).to.equal("bar4"); + expect(Cookies.get("test5", { request })).to.equal(""); + expect(Cookies.get("($;enc:)", { request })).to.equal("(i$xx:x;)"); + expect(Cookies.get("----", { request })).to.equal(`{"test":"12345","flag":true}`); + expect(Cookies.get("qwer", { request })).to.equal(undefined); + expect(Cookies.get("AID", { request })).to.equal( + "wmlspartner=wmtlabs:reflectorid=0085370:lastupd=146984" + ); + expect(Cookies.get("com.wm.reflector", { request })).to.equal( + "wmlspartner:abcd@lastupd:456@reflectorid:qwerty" + ); + reply({ now: Date.now() }); + } catch (err) { + reply(err.toString()).code(500); + } + }; + + const serverConfig = makeConfig(); + + return startServer(serverConfig).then(server => { + server.route({ + method: "get", + path: "/test", + handler + }); + + return new Promise((resolve, reject) => { + superAgent("http://localhost:3000/test") + .set( + "cookie", + 'com.wm.reflector="wmlspartner:abcd@lastupd:456@reflectorid:qwerty";AID=wmlspartner%3Dwmtlabs%3Areflectorid%3D0085370%3Alastupd%3D146984;test=bar;test2=bar2;test3=bar3;test4=bar4;test5=;%28$%3Benc%3A%29=(i%24xx%3Ax%3B);----={%22test%22%3A%2212345%22%2C%22flag%22%3Atrue};' + ) // eslint-disable-line + .end(err => { + return err ? reject(err) : resolve(); + }); + }); + }); + }); + + it("should get cookie by matching substring", () => { + const handler = (request, reply) => { + try { + expect(Cookies.get("te", { matchSubStr: true, request })).to.deep.equal({ + test: "bar", + test2: "bar2", + test3: "bar3", + test4: "bar4", + test5: "" + }); + expect(Cookies.get("!$key", { skipEncoding: true, request })).to.equal("(i$xx:x;)"); + expect(Cookies.get("!$k", { matchSubStr: true, request })).to.deep.equal({ + "!$key": "(i$xx:x;)" + }); + expect( + Cookies.get("%25!$foo", { matchSubStr: true, skipEncoding: true, request }) + ).to.deep.equal({ "%25!$foo-key": "%%%%" }); + expect(Cookies.get("----", { request })).to.equal(`{"test":"12345","flag":true}`); + + expect(Cookies.get("qwer", { request })).to.be.undefined; + expect(Cookies.get("AID", { request })).to.equal( + "wmlspartner=wmtlabs:reflectorid=0085370:lastupd=146984" + ); + expect(Cookies.get("com.wm.reflector", { request })).to.equal( + "wmlspartner:abcd@lastupd:456@reflectorid:qwerty" + ); + reply({ now: Date.now() }); + } catch (err) { + reply(err.toString()).code(500); + } + }; + + const serverConfig = makeConfig(); + + return startServer(serverConfig).then(server => { + server.route({ + method: "get", + path: "/test", + handler + }); + + return new Promise((resolve, reject) => { + superAgent("http://localhost:3000/test") + .set( + "cookie", + 'com.wm.reflector="wmlspartner:abcd@lastupd:456@reflectorid:qwerty";AID=wmlspartner%3Dwmtlabs%3Areflectorid%3D0085370%3Alastupd%3D146984;test=bar;test2=bar2;test3=bar3;test4=bar4;test5=;!$key=(i%24xx%3Ax%3B);----={%22test%22%3A%2212345%22%2C%22flag%22%3Atrue};%25!$foo-key=%%%%' + ) // eslint-disable-line + .end(err => { + return err ? reject(err) : resolve(); + }); + }); + }); + }); + + it("should throw when request not passed in options", () => { + expect(() => Cookies.set("test", "value")).to.throw(); + }); +}); diff --git a/packages/electrode-cookies/xclap.js b/packages/electrode-cookies/xclap.js new file mode 100644 index 000000000..ea371779c --- /dev/null +++ b/packages/electrode-cookies/xclap.js @@ -0,0 +1 @@ +require("electrode-archetype-njs-module-dev")(); diff --git a/packages/electrode-react-context/.eslintrc b/packages/electrode-react-context/.eslintrc new file mode 100644 index 000000000..5f6ea6525 --- /dev/null +++ b/packages/electrode-react-context/.eslintrc @@ -0,0 +1,3 @@ +--- +extends: + - "./node_modules/electrode-archetype-njs-module-dev/config/eslint/.eslintrc-node" diff --git a/packages/electrode-react-context/.npmignore b/packages/electrode-react-context/.npmignore new file mode 100644 index 000000000..c318ddda3 --- /dev/null +++ b/packages/electrode-react-context/.npmignore @@ -0,0 +1,229 @@ + +# Created by https://www.gitignore.io/api/osx,node + +### 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 + + +### 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 + +### Vim template +[._]*.s[a-w][a-z] +[._]s[a-w][a-z] +*.un~ +Session.vim +.netrwhist +*~ +### GitBook template +# 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 +### TortoiseGit template +# Project-level settings +/.tgitconfig +### Xcode template +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## Build generated +build/ +DerivedData + +## Various settings +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata + +## Other +*.xccheckout +*.moved-aside +*.xcuserstate +### Emacs template +# -*- mode: gitignore; -*- +*~ +\#*\# +/.emacs.desktop +/.emacs.desktop.lock +*.elc +auto-save-list +tramp +.\#* + +# Org-mode +.org-id-locations +*_archive + +# flymake-mode +*_flymake.* + +# eshell files +/eshell/history +/eshell/lastdir + +# elpa packages +/elpa/ + +# reftex files +*.rel + +# AUCTeX auto folder +/auto/ + +# cask packages +.cask/ +### OSX template +.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 +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio + +*.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 + +# 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 +/xclap.js +/.npmignore +.nyc_output +/test +/.eslintrc + diff --git a/packages/electrode-react-context/README.md b/packages/electrode-react-context/README.md new file mode 100644 index 000000000..624f1ba0b --- /dev/null +++ b/packages/electrode-react-context/README.md @@ -0,0 +1,99 @@ +# Electrode React Context + +Higher order React component used to add an `app` object property to the global React context. Each Electrode application can pass arbitrary props to make available to components via `this.context.app` but at a minimum should include the hapi `request`. + +## Installing + +``` +npm install electrode-react-context --save +``` + +## Usage + +The typical way to use the context is when contructing the app router. The `electrodeContext` wrapper is used to construct the parent Route component. + +~~~js +import electrodeContext from "electrode-react-context"; +import uiConfig from "electrode-ui-config"; + +// This function is invoked both on client and server. The `request` will be undefined on the client. +export const createRoutes = (request) => { + return ( + + + + + + + + ); +}; +~~~ + +Now all components in the app have access to an `app` property in the global context. This should be passed as the last argument to calls to functions exported by `electrode-ui-logger` and `electrode-cookies`. The `app.request` is needed for server-rendering since continuation local storage has been deprecated. By always passing in `app` component authors need not worry whether their component is rendering client or server-side. + +~~~js +import log from "electrode-ui-logger"; +import config from "electrode-ui-config"; + +export class Home extends React.Component { + constructor(props, context) { + super(props, context); + } + + render() { + log.info("Rendering Home component", this.context.app); + return ( + + + + ); + } +} + +Home.contextTypes = { + app: PropTypes.object +}; +~~~ + +### Functional Components + +Context is also available to functional components as the second argument. The `Home` component above could be re-written as: + +~~~js +export const Home = (props, {app}) => { + log.info("Rendering Home component", app); + return ( + + + + ); +}; +~~~ + +## Working with cookies + +The `electrode-cookies` module requires the `request` option when being invoked on the server. Since the `app` has the `request`, it can act as the `options` argument: + +~~~js +import Cookies from "electrode-cookies"; + +const CookieComponent = (props, {app}) => { + const cookieValue = Cookies.get("cookieName", app); + return
{cookieValue}
; +}; + +CookieComponent.contextTypes = { + app: PropTypes.object +}; +~~~ + +If you need to pass additional cookie specific options, you could do something like this: + +~~~js +Cookies.get("cookieName", Object.assign({}, app, { matchSubStr: true })); +~~~ \ No newline at end of file diff --git a/packages/electrode-react-context/lib/context-provider.js b/packages/electrode-react-context/lib/context-provider.js new file mode 100644 index 000000000..88c436b70 --- /dev/null +++ b/packages/electrode-react-context/lib/context-provider.js @@ -0,0 +1,26 @@ +"use strict"; +/* eslint-disable no-var */ + +var React = require("react"); +var PropTypes = require("prop-types"); +var createReactClass = require("create-react-class"); + +// This function is called both during SSR (from server/app.js) and CSR (client/app.jsx). The +// request arg will be undefined when called during CSR. +var contextProvider = function(Component, appContext) { + var ContextProvider = createReactClass({ + childContextTypes: { + app: PropTypes.object + }, + getChildContext: function() { + return {app: appContext}; + }, + render: function() { + return React.createElement(Component, this.props); + } + }); + + return ContextProvider; +}; + +module.exports = contextProvider; diff --git a/packages/electrode-react-context/package.json b/packages/electrode-react-context/package.json new file mode 100644 index 000000000..954b92d66 --- /dev/null +++ b/packages/electrode-react-context/package.json @@ -0,0 +1,53 @@ +{ + "name": "electrode-react-context", + "version": "1.0.0", + "description": "React HoC for providing app in the global context", + "main": "lib/context-provider.js", + "scripts": { + "test": "clap check", + "format": "prettier --write --print-width 100 *.js `find . -type d -d 1 -exec echo '{}/**/*.js' \\; | egrep -v '(/node_modules/|/dist/|/coverage/)'`" + }, + "keywords": [ + "react", + "context", + "electrode" + ], + "repository": { + "type": "git", + "url": "https://github.com/electrode-io/electrode.git" + }, + "license": "Apache-2.0", + "peerDependencies": { + "react": "^15.0.0 || ^0.14.0", + "react-dom": "^15.0.0 || ^0.14.0" + }, + "dependencies": { + "create-react-class": "^15.6.0", + "prop-types": "^15.5.10" + }, + "devDependencies": { + "electrode-archetype-njs-module-dev": "^2.2.0", + "react": "^15.0.0 || ^0.14.8", + "react-dom": "^15.0.0 || ^0.14.0" + }, + "nyc": { + "all": true, + "check-coverage": true, + "statements": 97.26, + "branches": 94.14, + "functions": 97.89, + "lines": 97.2, + "cache": true, + "reporter": [ + "lcov", + "text", + "text-summary" + ], + "exclude": [ + "coverage", + "*clap.js", + "gulpfile.js", + "test" + ] + } +} diff --git a/packages/electrode-react-context/test/.eslintrc b/packages/electrode-react-context/test/.eslintrc new file mode 100644 index 000000000..e9da91c41 --- /dev/null +++ b/packages/electrode-react-context/test/.eslintrc @@ -0,0 +1,3 @@ +--- +extends: + - "../node_modules/electrode-archetype-njs-module-dev/config/eslint/.eslintrc-test" \ No newline at end of file diff --git a/packages/electrode-react-context/test/mocha.opts b/packages/electrode-react-context/test/mocha.opts new file mode 100644 index 000000000..3caade9dd --- /dev/null +++ b/packages/electrode-react-context/test/mocha.opts @@ -0,0 +1,2 @@ +--require node_modules/electrode-archetype-njs-module-dev/config/test/setup.js +--recursive \ No newline at end of file diff --git a/packages/electrode-react-context/test/spec/context-provider.spec.js b/packages/electrode-react-context/test/spec/context-provider.spec.js new file mode 100644 index 000000000..0d48ba797 --- /dev/null +++ b/packages/electrode-react-context/test/spec/context-provider.spec.js @@ -0,0 +1,25 @@ +"use strict"; + +const React = require("react"); +const PropTypes = require("prop-types"); +const ReactDOMServer = require("react-dom/server"); +const contextProvider = require("../../lib/context-provider"); + +describe("contextProvider", () => { + it("exposes the app in React context", () => { + class Test extends React.Component { + render() { + return React.createElement("div", null, this.context.app.name); + } + } + + Test.contextTypes = { + app: PropTypes.object + }; + + const rendered = ReactDOMServer.renderToStaticMarkup( + React.createElement(contextProvider(Test, {name: "walmart"}))); + + expect(rendered).to.equal("
walmart
"); + }); +}); diff --git a/packages/electrode-react-context/xclap.js b/packages/electrode-react-context/xclap.js new file mode 100644 index 000000000..dff8954c4 --- /dev/null +++ b/packages/electrode-react-context/xclap.js @@ -0,0 +1,2 @@ +"use strict"; +require("electrode-archetype-njs-module-dev")(); \ No newline at end of file diff --git a/packages/electrode-react-webapp/.gitignore b/packages/electrode-react-webapp/.gitignore new file mode 100644 index 000000000..600d2d33b --- /dev/null +++ b/packages/electrode-react-webapp/.gitignore @@ -0,0 +1 @@ +.vscode \ No newline at end of file diff --git a/packages/electrode-react-webapp/.vscode/settings.json b/packages/electrode-react-webapp/.vscode/settings.json new file mode 100644 index 000000000..f70c630ee --- /dev/null +++ b/packages/electrode-react-webapp/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "editor.formatOnSave": true, + "prettier.printWidth": 100 +} diff --git a/packages/electrode-react-webapp/lib/index.html b/packages/electrode-react-webapp/lib/index.html index ed1da89e8..880970841 100644 --- a/packages/electrode-react-webapp/lib/index.html +++ b/packages/electrode-react-webapp/lib/index.html @@ -6,11 +6,11 @@ {{META_TAGS}} {{PAGE_TITLE}} {{CRITICAL_CSS}} - {{PREFETCH_BUNDLES}} {{WEBAPP_HEADER_BUNDLES}}
{{SSR_CONTENT}}
+{{PREFETCH_BUNDLES}} {{WEBAPP_BODY_BUNDLES}}