dwhite440
11/2/2014 - 7:59 PM

Template for HTA / browser React apps

Template for HTA / browser React apps

var exec = require('child_process').exec
var path = require('path')

var express = require('express')
var superagent = require('superagent')

var app = express()
var pkg = require('./package.json')

app.use(express.logger())
app.use(express.json())
app.use(app.router)
app.use(express.compress())
app.use(express.static(path.join(__dirname, './dist/browser')))
app.use(express.errorHandler({dumpExceptions: true, showStack: true }))

app.post('/proxy', function(req, res, next) {
  superagent.get(req.body.url)
    .auth(req.body.username, req.body.password)
    .accept('json')
    .end(function(err, apiRes) {
      if (err) { return next(err) }
      res.status(apiRes.status)
      res.type('json')
      res.send(apiRes.text)
    })
})

var port = process.env.PORT || 3000
var host = process.env.HOST || '0.0.0.0'
app.listen(port, host)
console.log(pkg.name + ' dev server listening on http://' + host + ':' + port)
{
  "name": "app",
  "version": "1.0.0",
  "description": "",
  "dependencies": {
    "async": "^0.9.0",
    "express": "^3.17.5",
    "moment": "^2.8.3",
    "react": "~0.11.2",
    "superagent": "~0.19.0"
  },
  "devDependencies": {
    "beepbeep": "^1.2.0",
    "browserify": "^5.12.1",
    "del": "^0.1.3",
    "envify": "^3.0.0",
    "gulp": "^3.8.8",
    "gulp-concat": "^2.4.1",
    "gulp-jshint": "^1.8.5",
    "gulp-minify-css": "^0.3.11",
    "gulp-plumber": "^0.6.5",
    "gulp-react": "^1.0.2",
    "gulp-rename": "^1.2.0",
    "gulp-streamify": "0.0.5",
    "gulp-template": "^1.1.1",
    "gulp-uglify": "^1.0.1",
    "gulp-util": "^3.0.1",
    "jshint-stylish": "^1.0.0",
    "vinyl-source-stream": "^1.0.0"
  },
  "scripts": {
    "dist": "gulp deps && gulp dist --runtime=browser --production && gulp dist --runtime=hta --production",
    "start": "node server.js"
  }
}
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>App</title>
  <link rel="stylesheet" href="deps.<%= cssExt %>">
  <link rel="stylesheet" href="style.<%= cssExt %>">
  <script src="deps.<%= jsExt %>"></script>
</head>
<body>
  <div id="app"></div>
  <script src="app.<%= jsExt %>"></script>
</body>
</html>
var fs = require('fs')

var beep = require('beepbeep')
var browserify = require('browserify')
var del = require('del')
var gulp = require('gulp')
var gutil = require('gulp-util')
var source = require('vinyl-source-stream')

var concat = require('gulp-concat')
var jshint = require('gulp-jshint')
var minifyCSS = require('gulp-minify-css')
var plumber = require('gulp-plumber')
var react = require('gulp-react')
var rename = require('gulp-rename')
var streamify = require('gulp-streamify')
var template = require('gulp-template')
var uglify = require('gulp-uglify')

var pkg = require('./package.json')
var runtime = gutil.env.runtime || 'browser'
if (!/^(?:browser|hta)$/.test(runtime)) {
  throw new Error('Invalid runtime: "' + runtime + '". Must be "browser" or "hta"')
}
var production = gutil.env.production

var cssSrcFiles = './public/css/style.css'
var cssExt = (production ? 'min.css' : 'css')

var jsSrcFiles = './src/**/*.js'
var jsxSrcFiles = jsSrcFiles + 'x'
var jsBuildFiles = './build/modules/**/*.js'
var jsExt = (production ? 'min.js' : 'js')

process.env.RUNTIME = runtime
process.env.NODE_ENV = (production ? 'production' : 'development')
process.env.VERSION = pkg.version

/** Prepare all deps */
gulp.task('deps', ['css-deps', 'js-deps'])

/** Build an external bundle containing all dependencies of app.js */
gulp.task('js-deps', function() {
  var b = browserify({detectGlobals: false})
  b.require('async')
  b.require('moment')
  b.require('react')
  b.require('superagent')
  b.transform('envify')

  return b.bundle()
    .pipe(source('deps.js'))
    .pipe(gulp.dest('./build'))
    .pipe(rename('deps.min.js'))
    .pipe(streamify(uglify()))
    .pipe(gulp.dest('./build'))
})

/** Bundle all CSS dependencies into deps.css */
gulp.task('css-deps', function() {
  gulp.src('./vendor/css/*.css')
    .pipe(concat('deps.css'))
    .pipe(gulp.dest('./build'))
    .pipe(rename('deps.min.css'))
    .pipe(minifyCSS())
    .pipe(gulp.dest('./build'))
})

/** Delete everything from /build/modules */
gulp.task('clean-modules', function(cb) {
  del('./build/modules/**', cb)
})

/** Copy non-jsx JavaScript to /build/modules */
gulp.task('copy-js', ['clean-modules'], function() {
  return gulp.src(jsSrcFiles)
    .pipe(gulp.dest('./build/modules'))
})

gulp.task('transpile-jsx', ['clean-modules'], function() {
  return gulp.src(jsxSrcFiles)
    .pipe(plumber())
    .pipe(react())
    .pipe(gulp.dest('./build/modules'))
})

/** Lint everything in /build/modules */
gulp.task('lint', ['copy-js', 'transpile-jsx'], function() {
  return gulp.src(jsBuildFiles)
    .pipe(jshint('./.jshintrc'))
    .pipe(jshint.reporter('jshint-stylish'))
})

var broken = false
var needsFixed = false

/** Bundle app.js */
gulp.task('build', ['lint'], function() {
  var b = browserify('./build/modules/app.js', {
    debug: runtime == 'browser' && !production
  , detectGlobals: false
  })
  b.external('async')
  b.external('moment')
  b.external('react')
  b.external('superagent')
  b.transform('envify')

  var stream = b.bundle()
    .on('error', function(err) {
      gutil.log(err.message)
      beep(2, 0)
      broken = true
      this.emit('end')
    })
    .on('end', function() {
      if (broken) {
        needsFixed = true
      }
      else if (needsFixed) {
        beep()
        needsFixed = false
      }
      broken = false
    })
    .pipe(source('app.js'))
    .pipe(gulp.dest('./build'))

  if (production) {
    stream = stream
      .pipe(rename('app.min.js'))
      .pipe(streamify(uglify()))
      .pipe(gulp.dest('./build'))
  }

  return stream
})

/* Copy CSS to /build and minify */
gulp.task('minify-css', function() {
  return gulp.src('./public/css/style.css')
    .pipe(gulp.dest('./build'))
    .pipe(rename('style.min.css'))
    .pipe(minifyCSS())
    .pipe(gulp.dest('./build'))
})

/** Delete everything from the appropriate /dist */
gulp.task('clean-dist', function(cb) {
  del('./dist/' + runtime, cb)
})

/** Copy CSS and JavaScript to /dist for browser builds  */
gulp.task('copy-dist', ['clean-dist', 'build', 'minify-css'], function(cb) {
  if (runtime == 'browser') {
    var sources = ['./build/*.' + jsExt, './build/*.' + cssExt]
    if (!production) {
      sources = sources.concat(['!./build/*.min.js', '!./build/*.min.css'])
    }
    return gulp.src(sources).pipe(gulp.dest('./dist/browser'))
  }
  cb()
})

/** Template the appropriate file for the target runtime */
gulp.task('dist', ['copy-dist'], function() {
  if (runtime == 'browser') {
    gulp.src('./templates/index.html')
      .pipe(template({
        cssExt: cssExt
      , jsExt: jsExt
      }))
      .pipe(gulp.dest('./dist/browser'))
  }
  if (runtime == 'hta') {
    gulp.src('./templates/' + pkg.name + '.hta')
      .pipe(template({
        css: fs.readFileSync('./build/style.' + cssExt)
      , cssDeps: fs.readFileSync('./build/deps.' + cssExt)
      , js: fs.readFileSync('./build/app.' + jsExt)
      , jsDeps: fs.readFileSync('./build/deps.' + jsExt)
      }))
      .pipe(gulp.dest('./dist/hta'))
  }
})

gulp.task('watch', function() {
  gulp.watch([jsSrcFiles,jsxSrcFiles,'./public/css/*.css','./templates/*'], ['dist'])
})

gulp.task('default', ['watch'])
<!DOCTYPE html>
<html>
<head>
  <hta:application applicationname="App" scroll="yes" singleinstance="yes">
  <meta http-equiv="x-ua-compatible" content="ie=9">
  <meta charset="utf-8">
  <title>App</title>
  <script>
/**
 * Base64 encode / decode
 * http://www.webtoolkit.info/
 * Used to shim window.btoa for IE9, for superagent basic auth.
 */
!function(){"use strict";var Base64={_keyStr:"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",encode:function(a){var c,d,e,f,g,h,i,b="",j=0;for(a=Base64._utf8_encode(a);j<a.length;)c=a.charCodeAt(j++),d=a.charCodeAt(j++),e=a.charCodeAt(j++),f=c>>2,g=(3&c)<<4|d>>4,h=(15&d)<<2|e>>6,i=63&e,isNaN(d)?h=i=64:isNaN(e)&&(i=64),b=b+this._keyStr.charAt(f)+this._keyStr.charAt(g)+this._keyStr.charAt(h)+this._keyStr.charAt(i);return b},decode:function(a){var c,d,e,f,g,h,i,b="",j=0;for(a=a.replace(/[^A-Za-z0-9\+\/\=]/g,"");j<a.length;)f=this._keyStr.indexOf(a.charAt(j++)),g=this._keyStr.indexOf(a.charAt(j++)),h=this._keyStr.indexOf(a.charAt(j++)),i=this._keyStr.indexOf(a.charAt(j++)),c=f<<2|g>>4,d=(15&g)<<4|h>>2,e=(3&h)<<6|i,b+=String.fromCharCode(c),64!=h&&(b+=String.fromCharCode(d)),64!=i&&(b+=String.fromCharCode(e));return b=Base64._utf8_decode(b)},_utf8_encode:function(a){a=a.replace(/\r\n/g,"\n");for(var b="",c=0;c<a.length;c++){var d=a.charCodeAt(c);128>d?b+=String.fromCharCode(d):d>127&&2048>d?(b+=String.fromCharCode(192|d>>6),b+=String.fromCharCode(128|63&d)):(b+=String.fromCharCode(224|d>>12),b+=String.fromCharCode(128|63&d>>6),b+=String.fromCharCode(128|63&d))}return b},_utf8_decode:function(a){var d,e,f,g,b="",c=0;for(d=e=f=0;c<a.length;)d=a.charCodeAt(c),128>d?(b+=String.fromCharCode(d),c++):d>191&&224>d?(f=a.charCodeAt(c+1),b+=String.fromCharCode((31&d)<<6|63&f),c+=2):(f=a.charCodeAt(c+1),g=a.charCodeAt(c+2),b+=String.fromCharCode((15&d)<<12|(63&f)<<6|63&g),c+=3);return b}};window.btoa=Base64.encode.bind(Base64);}();
/**
 * console-shim 1.0.3
 * https://github.com/kayahr/console-shim
 * Copyright (C) 2011 Klaus Reimer <k@ailis.de>
 * Licensed under the MIT license
 */
!function(){"use strict";var bind=function(a,b){var c=Array.prototype.slice.call(arguments,2);return function(){var d=c.concat(Array.prototype.slice.call(arguments,0));a.apply(b,d)}};window.console||(window.console={});var console=window.console;if(!console.log)if(window.log4javascript){var log=log4javascript.getDefaultLogger();console.log=bind(log.info,log),console.debug=bind(log.debug,log),console.info=bind(log.info,log),console.warn=bind(log.warn,log),console.error=bind(log.error,log)}else console.log=function(){};if(console.debug||(console.debug=console.log),console.info||(console.info=console.log),console.warn||(console.warn=console.log),console.error||(console.error=console.log),null!=window.__consoleShimTest__||eval("/*@cc_on @_jscript_version <= 9@*/")){var wrap=function(a){var b,c,d,e;if(a=Array.prototype.slice.call(arguments,0),e=a.shift(),c=a.length,c>1&&window.__consoleShimTest__!==!1)for("string"!=typeof a[0]&&(a.unshift("%o"),c+=1),d=a[0].match(/%[a-z]/g),b=d?d.length+1:1;c>b;b+=1)a[0]+=" %o";Function.apply.call(e,console,a)};console.log=bind(wrap,window,console.log),console.debug=bind(wrap,window,console.debug),console.info=bind(wrap,window,console.info),console.warn=bind(wrap,window,console.warn),console.error=bind(wrap,window,console.error)}if(console.assert||(console.assert=function(){var a=Array.prototype.slice.call(arguments,0),b=a.shift();b||(a[0]="Assertion failed: "+a[0],console.error.apply(console,a))}),console.dir||(console.dir=console.log),console.dirxml||(console.dirxml=console.log),console.exception||(console.exception=console.error),!console.time||!console.timeEnd){var timers={};console.time=function(a){timers[a]=(new Date).getTime()},console.timeEnd=function(a){var b=timers[a];b&&(console.log(a+": "+((new Date).getTime()-b)+"ms"),delete timers[a])}}console.table||(console.table=function(a,b){var c,d,e,f,g,h;if(a&&a instanceof Array&&a.length){if(!(b&&b instanceof Array)){b=[];for(h in a[0])a[0].hasOwnProperty(h)&&b.push(h)}for(c=0,d=a.length;d>c;c+=1){for(e=[],f=0,g=b.length;g>f;f+=1)e.push(a[c][b[f]]);Function.apply.call(console.log,console,e)}}}),console.clear||(console.clear=function(){}),console.trace||(console.trace=function(){}),console.group||(console.group=function(){}),console.groupCollapsed||(console.groupCollapsed=function(){}),console.groupEnd||(console.groupEnd=function(){}),console.timeStamp||(console.timeStamp=function(){}),console.profile||(console.profile=function(){}),console.profileEnd||(console.profileEnd=function(){}),console.count||(console.count=function(){})}();
<%= jsDeps %>
  </script>
  <style>
<%= cssDeps %>
<%= css %>
  </style>
</head>
<body>
  <div id="app"></div>
  <script>
<%= js %>
  </script>
</body>
</html>

Template for HTA / browser React apps

If you work in an organisation that runs Windows, an HTML Application (HTA) is a handy way to distribute browser-based tools as a single file, which needs nothing additional installed to run and doesn't need to have a multi-MB runtime distributed with it.

HTAs run Internet Explorer with the permissions of a "fully trusted" application, bypassing cross-domain restrictions for HTTP requests and granting access to the filesystem and, well... pretty much everything else on Windows (e.g. you don't need the data: URI hack to export an HTML table to Excel when you can create an Excel.Application object!)

I've recently been using React (for painless UI components) and superagent (for HTTP requests) to develop apps which directly hit the REST API of our intranet installation of Crucible to slice and dice Project and Code Review data in various ways.

Actually developing an app as an HTA is a hellish experience, so the goal of this template is to allow you to develop the majority of the app in a proper browser (keeping the limitations of IE in mind and learning new ones as you go!) and only have to drop down to HTA for testing and HTA-only code.

This template is the skeleton of the second app I've built this way - its build automates a bunch of stuff I was doing manually first time around, like creating a final HTA build which bundles all the project's minified JavaScript and CSS into a single file.

Project Structure

<app>
├── build               build directory
├── dist
│   ├── browser         final browser build
│   └── hta             final HTA build
├── public
│   └── css
│       └── style.css   app-specific CSS
├── src
│   └── app.js          entrypoint for the application
├── templates
│   ├── index.html      template for browser version: handles .min.(css|js) extensions
│   └── <app>.hta       template for HTA version, inlines all CSS & JS
├── vendor
│   └── css             vendored CSS (e.g. Bootstrap)
└── server.js           dev server

npm Scripts

  • npm run dist - build all dependencies, build browser version & build HTA version
  • npm start - run the dev server to serve up the browser version and proxy HTTP requests to external APIs

Gulp Build

Running gulp or gulp watch will lint and rebuild (the browser version, by default) every time anything in /src, /public/css or /templates is modified.

If you break browserification of the source code (usually bad syntax or a bad require()) the build will beep twice. It will beep once when the problem is subsequently fixed.

Tasks which will need tweaks from project to project, based on dependencies:

  • deps - bundle all CSS and JavaScript dependencies into single files and create minified versions
    • js-deps - use browserify require() calls to bundle (and alias, if necessary) JavaScript dependencies, installed via npm
    • css-deps - concatenate and minify /vendor/css
  • build - bundle app code, starting from /src/app.js. Dependencies from the js-deps tasks must be mirrored by calls to browserify's external() here

Flags which can be passed to the Gulp build:

  • --production - when passed, minified versions of CSS & JS will be generated and used. Otherwise, original versions will be used and the browser version's JavaScript will include a sourcemap.
  • --runtime=(browser|hta) - controls which version gets built. Defaults to "browser". You should only need this if you want to build the HTA version on every change while testing it or working on HTA-only code: gulp watch --runtime=hta

Environment variables, envify and uglify

The Gulp build sets up the following environment variables for use in your code. The build uses envify, so references to these in your code will be replaced with a string containing the value of the environment variable:

  • process.env.RUNTIME - "browser" or "hta", depending on which version is being built
  • process.env.NODE_ENV - "development" or "production", depending on which version is being built
  • process.env.VERSION - the contents of the version field from package.json

This allows you to fence off code for each version - e.g. to hit an API URL directly from the HTA version, but go through the dev server's /proxy URL for the browser version, you might do something like:

var req
if ('browser' === process.env.RUNTIME) {
  req = superagent.post('/proxy').send({
    url: url
  , username: credentials.username
  , password: credentials.password
  })
}
if ('hta' === process.env.RUNTIME) {
  req = superagent.get(url).auth(credentials.username, credentials.password)
}

req.accept('json').end(function(err, res) {
// ...

When uglify is run during a --production build, its dead code elimination will identify code which will never get executed for the runtime that's currently being built (which will now look like, e.g if ('hta' === "browser")) and remove the code from the minified version entirely.

{
  "browser": true,
  "node": true,

  "curly": true,
  "devel": true,
  "globals": {
    "ActiveXObject": true,
    "async": true,
    "moment": true,
    "React": true,
    "superagent": true
  },
  "noempty": true,
  "newcap": false,
  "undef": true,
  "unused": "vars",

  "asi": true,
  "boss": true,
  "eqnull": true,
  "expr": true,
  "funcscope": true,
  "globalstrict": true,
  "laxbreak": true,
  "laxcomma": true,
  "loopfunc": true,
  "sub": true
}