Browse Source

Initial commit; 1.0.0

master
Sven Slootweg 2 years ago
commit
d28a7cd1dd
  1. 3
      .eslintrc
  2. 1
      .gitignore
  3. 214
      README.md
  4. 60
      index.js
  5. 34
      operations/bundle.js
  6. 88
      operations/development-server.js
  7. 9
      operations/production-server.js
  8. 25
      package.json
  9. 12
      validation/is-absolute-path.js
  10. 10
      validation/is-writable-stream.js
  11. 9
      validation/validate-express-app.js
  12. 32
      validation/validate-options.js
  13. 3472
      yarn.lock

3
.eslintrc

@ -0,0 +1,3 @@
{
"extends": "@joepie91/eslint-config"
}

1
.gitignore

@ -0,0 +1 @@
node_modules

214
README.md

@ -0,0 +1,214 @@
# budo-express - Auto-reload superpowers for Express!
Get client-side code bundling and automagical live-reloading working in your Express app, in 5 minutes!
This library integrates a [Budo](https://github.com/mattdesl/budo) development server into your Express application. It uses Browserify for bundling, and LiveReload for page reloading. It combines really well with `nodemon`, for auto-restarting your server process too!
Because this package doesn't rely on any generators or boilerplates, you can easily integrate it into your *existing* Express application.
## What about Webpack?
This library uses Browserify, which is a bundler just like Webpack - however, Browserify is much simpler to understand and configure. It can do the same things as Webpack can, and you won't need Webpack when using Browserify. It's actually been around for longer than Webpack!
## Usage
Because `budo-express` is meant to integrate into your own Express app, it's exposed as a library rather than a command-line utility.
You simply start by creating your own "binary", something like `bin/server.js`, and then call `budo-express` from there, handing it your Express app as an argument:
```js
"use strict";
const budoExpress = require("budo-express");
const path = require("path");
budoExpress({
port: 3000,
expressApp: require("../src/server/app"),
basePath: path.join(__dirname, ".."),
entryFiles: "src/client/index.jsx",
staticPath: "public",
bundlePath: "js/bundle.js",
livereloadPattern: "**/*.{css,html,js,svg}",
browserify: {
extensions: [".jsx"],
transform: [
["babelify", {
presets: ["@babel/preset-env", "@babel/preset-react"],
}]
]
}
});
```
(Don't worry about all those options for now - they're all explained below!)
And in your `src/server/app.js` -- note that __you never call `app.listen`__, and you *export* your app instead:
```js
"use strict";
const express = require("express");
let app = express();
// ... your Express app stuff goes here ...
module.exports = app;
```
Now you simply run:
```sh
NODE_ENV=development node bin/server.js
```
... and that's it! You now have a fully-functioning development server, that:
- bundles `src/client/index.jsx` into `public/js/bundle.js`
- transforms JSX and ES6+ using Babel
- automatically reloads the browser when any `.css`, `.html`, `.js`, or `.svg` files in the `public/` folder change.
There's only one thing missing; your server process won't auto-restart when your *server* code changes. That's easy to fix, when you have [`nodemon`](https://nodemon.io/) installed:
```sh
NODE_ENV=development nodemon bin/server.js
```
(Note how we've just changed `node` to `nodemon`, and that's it!)
## More complex bundling configurations (eg. CSS modules)
The `browserify` option accepts any kind of valid [Browserify configuration](https://www.npmjs.com/package/browserify#browserifyfiles--opts).
So for example, if we want to add support for CSS modules and nested CSS, we just install [`icssify`](https://www.npmjs.com/package/icssify) and [`postcss-nested`](https://www.npmjs.com/package/postcss-nested), and do this in our `bin/server.js`:
```js
"use strict";
const budoExpress = require("budo-express");
const path = require("path");
budoExpress({
port: 3000,
expressApp: require("../src/server/app"),
basePath: path.join(__dirname, ".."),
entryFiles: "src/client/index.jsx",
staticPath: "public",
bundlePath: "js/bundle.js",
livereloadPattern: "**/*.{css,html,js,svg}",
browserify: {
extensions: [".jsx"],
plugin: [
["icssify", {
before: [ require("postcss-nested")() ]
}]
],
transform: [
["babelify", {
presets: ["@babel/preset-env", "@babel/preset-react"],
}]
]
}
});
```
Note the added `plugin` list with `icssify`; that's the only difference.
## Running in production
Of course, we don't want to run a development server when we're really deploying our app. And when we're running it in production, we need a 'real' generated bundle, not just one that the development server generates on-the-fly.
There's only two steps needed to run your app in production. Step one is to generate the bundle:
```sh
BUDO_BUILD=1 node bin/server.js
```
Note how we're just running the same 'binary' again, but with a different environment variable. This tells `budo-express` that we want to generate a bundle instead. This doesn't require any extra configuration; it'll use the configuration we've specified in `bin/server.js`!
The second step is to actually *run* your application, but without the development server sitting in front of it:
```
node bin/server.js
```
That's it! We just leave off the environment variables, and it will default to production mode. Nothing will be auto-reloaded, `budo-express` will get out of the way, and all you're left with is your own Express application.
## About all those options...
In the example above, there were quite a few options - especially path-related options. This is because we're actually dealing with two different *kinds* of paths
1. Filesystem paths, ie. paths of folders/files on your computer
2. URL paths, ie. the `/some/path` part in `https://example.com/some/path`.
... and we need to deal with a number of different files; the input files, the output bundle, static files, and so on.
Here's a rundown of what all the path-related options mean:
__basePath:__ This is the filesystem path for your "project root", ie. where your `package.json` is. It must be an absolute path. The easiest way to generate this is to use [`path.join`](https://nodejs.org/api/path.html#path_path_join_paths) with [`__dirname`](https://nodejs.org/api/modules.html#modules_dirname) and the right amount of `../..` entries.
For example, in the example above, our `bin/server.js` is in `bin`, which is one folder deeper than the root of the project; so we need to combine it with a single `..` to get the project root.
__entryFiles:__ These are the files - __relative to the `basePath` (project root)__ - that the bundler should start with; basically, the files that contain the code that should be executed right when the bundle loads. The bundler will then follow all the `require(...)` statements starting from those files, collecting everything together. Note that this can be __either a single path, or an array of them__.
__staticPath:__ This is the filesystem path - again, relative to the `basePath` - where your static files (CSS stylesheets, images...) are stored.
__bundlePath:__ This is the filesystem path - __relative to both `staticPath` *and* the URL root of your static files__ (see the `staticPrefix` option below) - where your bundle should be saved. So if `staticPath` is `"public/"`, and `bundlePath` is `"js/bundle.js"`, the final path for your generated bundle will be `"public/js/bundle.js"` (relative to the project root).
__livereloadPattern:__ This is the [glob pattern](https://www.npmjs.com/package/picomatch#globbing-features) - relative to `staticPath` - that defines which files should be live-reloaded when they changed. Anything in the static files folder that matches this pattern, will be reloaded; anything that does not, will not.
And then there's a bunch of required non-path options:
__port:__ The port that your server should run on, __both in development and production mode__.
__expressApp:__ The actual Express application that the development server should be integrated into (or, in production mode, the application that should be run).
__browserify:__ The [Browserify configuration](https://www.npmjs.com/package/browserify#browserifyfiles--opts) to use for bundling.
## Optional extras
Finally, there's a handful of __optional__ options:
__staticPrefix:__ The *URL path*, relative to the root of your domain/hostname, where your static file folder is publicly exposed. If you're exposing it at the root (the default result of doing `app.use(express.static(...))` without specifying a middleware prefix), leave this undefined.
__host:__ The host to listen on in production mode. Defaults to all network interfaces (`"::"`).
__allowUnsafeHost:__ By default, in development mode, the development server will only listen on `localhost`, so that noone can access it from outside your computer; this is for security reasons. If you want to disable this and use the host specified in `host` instead, you can set this option to `true` -- however, only do this if you fully understand the risks!
__sourceMaps:__ Whether to enable Browserify's [sourcemaps](https://www.html5rocks.com/en/tutorials/developertools/sourcemaps/) in development mode. Defaults to `true`.
__developmentMode:__ Whether to run in development mode (`true`), production mode (`false`), or auto-detect it based on the `NODE_ENV` environment variable like in the examples (`"auto"`). Defaults to auto-detection.
__middleware:__ Custom Connect/Express-style middleware to run __in development mode only__, before a request reaches your application. Don't use this for normal application middleware! You should almost never need this option.
__livereloadPort:__ The port that the LiveReload server should listen on. Changing this will almost certainly break any LiveReload browser extensions. Defaults to `35729`.
__stream:__ A Writable stream to log ndjson output from Budo to. This defaults to `process.stdout` (ie. your terminal).
Altogether, the various paths are composed like this:
Path | Calculated as
-----|--------------
Static files filesystem path | `basePath` + `staticPath`
Static files URL path | root of your domain + `staticPrefix`
Bundle filesystem path | `basePath` + `staticPath` + `bundlePath`
Bundle URL path | root of your domain + `staticPrefix` + `bundlePath`
Pattern for livereloaded file matching | `basePath` + `staticPath` + `livereloadPattern`
Entry files filesystem path | `basePath` + `entryFiles`
## API
### expressBudo(options)
Depending on whether `BUDO_BUILD=1` is specified:
- If yes, starts an instance of your Express application; with, if development mode is enabled (`NODE_ENV=development` or `developmentMode: true`), a development server integrated into it.
- If no, generates and saves a bundle based on your configuration.
Arguments:
- __options:__ The options, as documented above.
## Changelog
### v1.0.0 (February 16, 2020)
Initial release.

60
index.js

@ -0,0 +1,60 @@
"use strict";
const defaultValue = require("default-value");
const assureArray = require("assure-array");
const path = require("path");
const validateOptions = require("./validation/validate-options");
const bundle = require("./operations/bundle");
const productionServer = require("./operations/production-server");
const developmentServer = require("./operations/development-server");
function getDevelopmentMode(developmentModeSetting) {
if (developmentModeSetting === "auto") {
return (process.env.NODE_ENV === "development");
} else {
/* Hard-coded true or false */
return developmentModeSetting;
}
}
module.exports = function budoExpress(options = {}) {
validateOptions(options);
function projectPath(target) {
return path.resolve(options.basePath, target);
}
let staticBasePath = projectPath(options.staticPath);
let context = {
options: options,
entryPaths: assureArray(options.entryFiles).map((file) => projectPath(file)),
staticBasePath: staticBasePath,
staticPath: function staticPath(target) {
return path.resolve(staticBasePath, target);
}
};
if (process.env.BUDO_BUILD === "1") {
bundle(context);
} else {
let productionHost = defaultValue(options.host, "::");
let developmentHost = (options.allowUnsafeHost) ? productionHost : "127.0.0.1";
let developmentModeSetting = defaultValue(options.developmentMode, "auto");
let developmentMode = getDevelopmentMode(developmentModeSetting);
if (developmentMode) {
developmentServer({
... context,
host: developmentHost
});
} else {
productionServer({
... context,
host: productionHost
});
}
}
};

34
operations/bundle.js

@ -0,0 +1,34 @@
"use strict";
const Promise = require("bluebird");
const path = require("path");
const browserify = require("browserify");
const util = require("util");
const fs = require("fs");
const chalk = require("chalk");
let mkdirAsync = util.promisify(fs.mkdir);
module.exports = function bundle({ options, staticPath, entryPaths }) {
let targetBundlePath = staticPath(options.bundlePath);
let targetFolder = path.dirname(targetBundlePath);
let browserifyOptions = {
debug: options.sourceMaps,
... options.browserify
};
let browserifyInstance = browserify(browserifyOptions);
browserifyInstance.add(entryPaths);
return Promise.try(() => {
return mkdirAsync(targetFolder, { recursive: true });
}).then(() => {
browserifyInstance
.bundle()
.pipe(fs.createWriteStream(targetBundlePath))
.on("finish", () => {
console.log(`${chalk.bold.green("Bundle generated:")} ${targetBundlePath}`);
});
});
};

88
operations/development-server.js

@ -0,0 +1,88 @@
"use strict";
const budo = require("budo");
const defaultValue = require("default-value");
const assureArray = require("assure-array");
const path = require("path");
const entities = require("entities");
let reloadClientTag = "<script src=\"/budo/livereload.js\"></script>";
function monkeyPatchWrite(res) {
// NOTE: This is a hack, to auto-inject the Budo livereload client in any outgoing HTML response (but only in development mode).
let write = res.write.bind(res);
res.write = function monkeyPatchedWrite(... args) {
if (res.getHeader("content-type").startsWith("text/html")) {
let contentLength = res.getHeader("content-length");
if (contentLength != null) {
// Compensate for the additional bytes introduced by our injected script tag
res.setHeader("content-length", contentLength + reloadClientTag.length);
}
write(reloadClientTag);
}
// Reset the write method back to the original method; we don't need to get in the way anymore
res.write = write;
write(... args);
};
}
function createExpressMiddleware(app) {
return function expressMiddleware(req, res, next) {
monkeyPatchWrite(res);
app.handle(req, res, (err) => {
if (err != null && err instanceof Error) {
res
.status(500)
.send(`${reloadClientTag}<pre>${entities.escape(err.stack)}</pre>`);
} else {
next(err);
}
});
};
}
module.exports = function ({ options, staticPath, staticBasePath, entryPaths, host }) {
let middlewareList;
if (options.middleware != null) {
middlewareList = assureArray(options.middleware).concat([
createExpressMiddleware(options.expressApp)
]);
} else {
middlewareList = [
createExpressMiddleware(options.expressApp)
];
}
let budoOptions = {
watchGlob: staticPath(options.livereloadPattern),
live: options.livereloadPattern,
livePort: defaultValue(options.livereloadPort, 35729),
stream: defaultValue(options.stream, process.stdout),
port: options.port,
host: host,
dir: staticBasePath,
serve: (options.staticPrefix != null)
? path.join(options.staticPrefix, options.bundlePath)
: options.bundlePath,
browserify: options.browserify,
middleware: middlewareList,
debug: defaultValue(options.sourceMaps, true),
};
let devServer = budo(entryPaths, budoOptions).on("connect", (event) => {
let reloadServer = event.webSocketServer;
reloadServer.once("connection", (_socket) => {
// This is to make sure the browser also reloads after the process has auto-restarted (eg. using `nodemon`)
console.log("Triggering initial page refresh for new client...");
devServer.reload("*");
});
});
};

9
operations/production-server.js

@ -0,0 +1,9 @@
"use strict";
module.exports = function productionServer({ options, host }) {
options.expressApp
.listen({ port: options.port, host: host })
.on("listening", (_event) => {
console.log(`Production server running on ${(host === "::") ? "*" : host}:${options.port}`);
});
};

25
package.json

@ -0,0 +1,25 @@
{
"name": "budo-express",
"description": "A small wrapper to integrate Budo with an Express application",
"version": "1.0.0",
"main": "index.js",
"repository": "git@git.cryto.net:joepie91/budo-express.git",
"bugs": { "url": "https://git.cryto.net/joepie91/budo-express/issues" },
"author": "Sven Slootweg <admin@cryto.net>",
"license": "WTFPL OR CC0-1.0",
"dependencies": {
"assure-array": "^1.0.0",
"bluebird": "^3.7.2",
"browserify": "^16.5.0",
"budo": "^11.5.0",
"chalk": "^3.0.0",
"default-value": "^1.0.0",
"entities": "^2.0.0",
"is-stream": "^2.0.0",
"validatem": "^0.2.0"
},
"devDependencies": {
"@joepie91/eslint-config": "^1.1.0",
"eslint": "^6.8.0"
}
}

12
validation/is-absolute-path.js

@ -0,0 +1,12 @@
"use strict";
const { isString, ValidationError } = require("validatem");
const path = require("path");
module.exports = function isAbsolutePath(value) {
isString(value);
if (!path.isAbsolute(value)) {
throw new ValidationError(`Must be an absolute path`);
}
};

10
validation/is-writable-stream.js

@ -0,0 +1,10 @@
"use strict";
const { ValidationError } = require("validatem");
const isStream = require("is-stream");
module.exports = function isWritableStream(value) {
if (!isStream.writable(value)) {
throw new ValidationError(`Must be a writable stream`);
}
};

9
validation/validate-express-app.js

@ -0,0 +1,9 @@
"use strict";
const { ValidationError } = require("validatem");
module.exports = function validateExpressApp(value) {
if (typeof value.handle !== "function") {
throw new ValidationError(`Must be an Express application`);
}
};

32
validation/validate-options.js

@ -0,0 +1,32 @@
"use strict";
const { validateOptions, required, isString, isBoolean, isNumber, isFunction, oneOf, allowExtraProperties, arrayOf, either } = require("validatem");
const validateExpressApp = require("./validate-express-app");
const isWritableStream = require("./is-writable-stream");
const isAbsolutePath = require("./is-absolute-path");
module.exports = function validateBudoExpressOptions(_options) {
validateOptions(arguments, {
expressApp: [ required, validateExpressApp ],
port: [ required, isNumber ],
livereloadPattern: [ required, isString ],
browserify: [ required, allowExtraProperties({}) ],
basePath: [ required, isAbsolutePath ],
entryFiles: [ required, either(
isString,
arrayOf(isString)
)],
staticPath: [ required, isString ],
bundlePath: [ required, isString ],
staticPrefix: isString,
host: isString,
allowUnsafeHost: isBoolean,
developmentMode: oneOf([ true, false, "auto" ]),
middleware: arrayOf(isFunction),
stream: isWritableStream,
livereloadPort: isNumber,
sourceMaps: isBoolean
});
};

3472
yarn.lock
File diff suppressed because it is too large
View File

Loading…
Cancel
Save