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>
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.
<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 run dist
- build all dependencies, build browser version & build HTA versionnpm start
- run the dev server to serve up the browser version and proxy HTTP requests to external APIsRunning 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 npmcss-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()
hereFlags 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 builtprocess.env.NODE_ENV
- "development"
or "production"
, depending on which version is being builtprocess.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
}