├── .gitignore ├── .npmignore ├── types ├── babel-plugin.d.ts └── strip-ansi.d.ts ├── .prettierrc.json ├── .editorconfig ├── tsconfig.json ├── lib ├── util.js ├── fs-async.js ├── watch.js ├── options.js └── index.js ├── package.json ├── README.md └── .eslintrc.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .* 2 | /types 3 | tsconfig.json 4 | tslint.json 5 | -------------------------------------------------------------------------------- /types/babel-plugin.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'babel-plugin-*' { 2 | const plugin: any 3 | export = plugin 4 | } 5 | -------------------------------------------------------------------------------- /types/strip-ansi.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'strip-ansi' { 2 | function stripAnsi(data: string): string 3 | export = stripAnsi 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": false, 4 | "printWidth": 100, 5 | "semi": false, 6 | "singleQuote": true, 7 | "trailingComma": "all" 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "checkJs": true, 5 | "lib": ["esnext"], 6 | "noEmit": true, 7 | "moduleResolution": "node", 8 | "skipLibCheck": true, 9 | "strict": true, 10 | "target": "esnext", 11 | "typeRoots": ["node_modules/@types", "types"] 12 | }, 13 | "include": ["lib", "types"] 14 | } 15 | -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | const {join} = require('path').posix 2 | const {DIR_MAP} = require('./options') 3 | 4 | /** 5 | * @param {string} path 6 | * @return {string} path 7 | */ 8 | module.exports.rewriteDir = path => { 9 | path = join('/', path) 10 | return Object.entries(DIR_MAP).reduce((path, [from, to]) => { 11 | from = join('/', from, '/') 12 | if (path.substr(0, from.length) == from) return join(to, path.substr(from.length)) 13 | else return path 14 | }, path) 15 | } 16 | -------------------------------------------------------------------------------- /lib/fs-async.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | 3 | /** @typedef {import('fs').Stats} FSStats */ 4 | 5 | /** 6 | * @param {string} path 7 | * @return {Promise} 8 | */ 9 | exports.readdir = path => 10 | new Promise((resolve, reject) => { 11 | fs.readdir(path, (err, files) => { 12 | if (err != null) reject(err) 13 | else resolve(files) 14 | }) 15 | }) 16 | 17 | /** 18 | * @param {string} path 19 | * @return {Promise} 20 | */ 21 | exports.stat = path => 22 | new Promise((resolve, reject) => { 23 | fs.stat(path, (err, stats) => { 24 | if (err != null) reject(err) 25 | else resolve(stats) 26 | }) 27 | }) 28 | 29 | /** 30 | * @param {string} path 31 | * @return {Promise} 32 | */ 33 | exports.readFile = path => 34 | new Promise((resolve, reject) => { 35 | fs.readFile(path, 'utf8', (err, data) => { 36 | if (err != null) reject(err) 37 | else resolve(data) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pwa-serve", 3 | "version": "0.2.1", 4 | "description": "Simple web server with babel compilation on the fly", 5 | "author": "phaux ", 6 | "license": "ISC", 7 | "repository": "phaux/es-serve", 8 | "keywords": [ 9 | "esnext", 10 | "server", 11 | "spa", 12 | "pwa", 13 | "babel" 14 | ], 15 | "bin": "lib/index.js", 16 | "main": "lib/index.js", 17 | "scripts": { 18 | "format": "prettier --write \"lib/**/*\" && eslint --fix .", 19 | "test": "eslint . && tsc -p ." 20 | }, 21 | "devDependencies": { 22 | "@types/babel__core": "^7.0.1", 23 | "@types/mime": "^2.0.0", 24 | "@types/node": "^10.12.0", 25 | "eslint": "^5.8.0", 26 | "prettier": "^1.16.4", 27 | "typescript": "^3.1.3" 28 | }, 29 | "dependencies": { 30 | "@babel/core": "^7.1.2", 31 | "any-cfg": "^0.9.0", 32 | "babel-plugin-bare-import-rewrite": "^1.0.0", 33 | "mime": "^2.3.1", 34 | "strip-ansi": "^5.1.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /lib/watch.js: -------------------------------------------------------------------------------- 1 | /** @type {Map} */ 2 | const watchers = new Map() 3 | 4 | const {watch} = require('fs') 5 | const {join} = require('path') 6 | const {stat, readdir} = require('./fs-async') 7 | 8 | /** 9 | * @param {string} path 10 | * @param {function(string): void} cb 11 | * @return {Promise} 12 | */ 13 | async function rwatch(path, cb) { 14 | const stats = await stat(path).catch(() => undefined) 15 | if (stats == null || !stats.isDirectory()) return 16 | const files = await readdir(path) 17 | await Promise.all(files.map(file => rwatch(join(path, file), cb))) 18 | const watcher = watch(path, async (ev, file) => { 19 | const subpath = join(path, file) 20 | if (ev == 'change') cb(subpath) 21 | else if (ev == 'rename') { 22 | const stats = await stat(subpath).catch(() => undefined) 23 | if (stats) { 24 | if (stats.isFile()) cb(subpath) 25 | if (stats.isDirectory()) rwatch(subpath, cb) 26 | } 27 | else { 28 | const watcher = watchers.get(subpath) 29 | if (watcher != null) { 30 | watcher.close() 31 | watchers.delete(subpath) 32 | } 33 | } 34 | } 35 | }) 36 | watchers.set(path, watcher) 37 | } 38 | 39 | const {EventEmitter} = require('events') 40 | const {WATCH} = require('./options') 41 | 42 | module.exports = 43 | WATCH == null 44 | ? undefined 45 | : (() => { 46 | const watcher = new EventEmitter() 47 | for (const dir of WATCH) rwatch(dir, file => watcher.emit('change', file)) 48 | return watcher 49 | })() 50 | -------------------------------------------------------------------------------- /lib/options.js: -------------------------------------------------------------------------------- 1 | const {config} = require('any-cfg') 2 | 3 | const help = ` 4 | HTTP file server with babel compiling on the fly. 5 | ` 6 | 7 | const cfg = config({ 8 | envPrefix: 'APP_', 9 | configFile: 'server.config', 10 | help, 11 | }).options({ 12 | PORT: { 13 | type: 'number', 14 | short: 'P', 15 | help: 'Server port', 16 | }, 17 | EXTENSIONS: { 18 | type: 'list', 19 | short: 'e', 20 | help: 'List of extensions interpreted as JS (default: js, jsx, mjs, ts, tsx)', 21 | }, 22 | DIR_MAP: { 23 | type: 'map', 24 | short: 'd', 25 | help: 'Directory rewrite rules (e.g. `dist=src`)', 26 | }, 27 | IGNORE: { 28 | type: 'list', 29 | short: 'i', 30 | help: 'Paths which will be omitted from compiling', 31 | }, 32 | VERBOSE: { 33 | type: 'boolean', 34 | short: 'v', 35 | help: 'Verbose logging mode', 36 | }, 37 | WATCH: { 38 | type: 'list', 39 | short: 'w', 40 | help: 'Enables watch mode and watches provided path', 41 | }, 42 | HELP: { 43 | type: 'boolean', 44 | short: 'h', 45 | help: 'Show help', 46 | }, 47 | }) 48 | 49 | const opt = cfg.parse() 50 | 51 | if (opt.HELP) { 52 | cfg.help() 53 | process.exit(0) 54 | } 55 | 56 | module.exports = { 57 | PORT: opt.PORT || 8000, 58 | EXTS: [ 59 | '.js', 60 | '.mjs', 61 | '.jsx', 62 | '.ts', 63 | '.tsx', 64 | ...(opt.EXTENSIONS || []).map(s => s.replace(/^\.?/, '.')), 65 | ], 66 | DIR_MAP: opt.DIR_MAP || {}, 67 | IGNORE: opt.IGNORE || [], 68 | VERBOSE: opt.VERBOSE, 69 | WATCH: opt.WATCH, 70 | ROOT: opt._[0] || '.', 71 | } 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PWA Serve 2 | 3 | [![npm](https://img.shields.io/npm/v/pwa-serve.svg)](https://www.npmjs.com/package/pwa-serve) 4 | 5 | Simple web server with babel compilation on the fly. In many cases it can be used as a replacement for webpack-dev-server or similar. 6 | 7 | **Example usage** 8 | 9 | ```sh 10 | pwa-serve --dir-map "dist=src" --watch "src" --watch "styles" 11 | ``` 12 | 13 | ## Features 14 | 15 | - History API fallback (serve `index.html` for unknown routes). 16 | - URL rewriting, useful for mapping your `dist` folder to `src`. 17 | - Compiling scripts on the fly with babel (with cache). Simply add babel config to your project. 18 | - Resolving bare imports via `babel-plugin-bare-import-rewrite` by default. 19 | - Watch mode and auto-refresh (like browser-sync). 20 | 21 | ## Options 22 | 23 | - `--port` **number** - 24 | Server port (default: 8000) 25 | 26 | - `--extensions` / `-e` **string** ... - 27 | Specify additional extensions interpreted as JS (default: js, jsx, mjs, ts, tsx) 28 | 29 | - `--dir-map` / `-d` **key=value** ... - 30 | Directory rewrite rules (e.g. `dist=src`) 31 | 32 | - `--ignore` / `-i` **string** ... - 33 | Paths which will be omitted from compiling 34 | 35 | - `--verbose` / `-v` - 36 | Verbose logging mode 37 | 38 | - `--watch` / `-w` **string** ... - 39 | Enables watch mode and watches provided paths 40 | 41 | Options can be also provided via `server.config.json` (or `.toml`) file (format `opt_name`) or via environment variables (format `APP_OPT_NAME`) 42 | 43 | ## Non-goals 44 | 45 | ### Asset bundling 46 | 47 | Browsers can't `import` styles nor images. If you want component-relative assets use `import.meta.url` and configure your bundler appropriately. If you need scoped CSS, use react's styled components or lit-html `` html`` ``. 48 | 49 | ### Hot module replacement 50 | 51 | Too complicated 52 | 53 | ## Changelog 54 | 55 | ### `0.2.0` 56 | 57 | - Initial version 58 | 59 | ### `0.2.1` 60 | 61 | - Fix 404 on windows 62 | - Search babel config relative to provided root path 63 | - Send status text on error 64 | - Strip ANSI color codes from errors 65 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "env": { 4 | "node": true, 5 | "es6": true 6 | }, 7 | "parserOptions": { 8 | "ecmaVersion": 8 9 | }, 10 | "rules": { 11 | "array-bracket-spacing": ["error", "never"], 12 | "arrow-body-style": ["error", "as-needed"], 13 | "arrow-parens": ["error", "as-needed"], 14 | "arrow-spacing": "error", 15 | "block-spacing": ["error", "always"], 16 | "brace-style": ["error", "stroustrup", {"allowSingleLine": true}], 17 | "comma-dangle": ["error", "always-multiline"], 18 | "comma-spacing": "error", 19 | "comma-style": "error", 20 | "computed-property-spacing": "error", 21 | "dot-location": ["error", "property"], 22 | "dot-notation": "error", 23 | "eol-last": "error", 24 | "generator-star-spacing": ["error", "before"], 25 | "indent": ["error", 2], 26 | "key-spacing": ["error", {"mode": "minimum"}], 27 | "keyword-spacing": "error", 28 | "linebreak-style": "error", 29 | "max-len": ["error", 100, 2], 30 | "no-caller": "error", 31 | "no-console": "off", 32 | "no-constant-condition": "off", 33 | "no-empty": "off", 34 | "no-invalid-this": "error", 35 | "no-labels": "error", 36 | "no-lone-blocks": "error", 37 | "no-multiple-empty-lines": "error", 38 | "no-restricted-syntax": ["error", "SwitchStatement", "ForStatement", "ForInStatement"], 39 | "no-self-compare": "error", 40 | "no-sequences": "error", 41 | "no-spaced-func": "error", 42 | "no-throw-literal": "error", 43 | "no-unmodified-loop-condition": "error", 44 | "no-unused-expressions": ["error", {"allowShortCircuit": true, "allowTernary": true}], 45 | "no-use-before-define": ["error", {"functions": false}], 46 | "no-useless-concat": "error", 47 | "no-useless-escape": "error", 48 | "no-var": "error", 49 | "no-void": "error", 50 | "no-warning-comments": "warn", 51 | "no-whitespace-before-property": "error", 52 | "no-with": "error", 53 | "object-curly-spacing": ["error", "never"], 54 | "object-shorthand": "error", 55 | "operator-assignment": ["error", "always"], 56 | "operator-linebreak": ["error", "before", {"overrides": {"=": "ignore"}}], 57 | "prefer-arrow-callback": "error", 58 | "prefer-const": "error", 59 | "require-jsdoc": "error", 60 | "rest-spread-spacing": "error", 61 | "quotes": ["error", "single", {"avoidEscape": true}], 62 | "semi": ["error", "never"], 63 | "semi-spacing": "error", 64 | "space-before-blocks": "error", 65 | "space-before-function-paren": ["error", {"anonymous": "always", "named": "never"}], 66 | "space-infix-ops": ["error", {"int32Hint": true}], 67 | "space-in-parens": "error", 68 | "space-unary-ops": "error", 69 | "template-curly-spacing": "error", 70 | "valid-jsdoc": ["error", {"requireParamDescription": false, "requireReturnDescription": false}], 71 | "yield-star-spacing": "error", 72 | "yoda": ["error", "never"] 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const {join, extname, parse, format} = require('path').posix 3 | const {createReadStream} = require('fs') 4 | const {readFile, stat} = require('./fs-async') 5 | const {transformFileAsync} = require('@babel/core') 6 | const {VERBOSE, IGNORE, EXTS, ROOT, PORT} = require('./options') 7 | const mime = require('mime') 8 | const watcher = require('./watch') 9 | const {rewriteDir} = require('./util') 10 | const stripAnsi = require('strip-ansi') 11 | 12 | /** @type {Map} */ 13 | const CACHE = new Map() 14 | 15 | const server = require('http').createServer(async (req, res) => { 16 | // only handle GET requests 17 | if (req.method != 'GET') { 18 | res.setHeader('Content-Type', 'text/plain; charset=UTF-8') 19 | res.statusCode = 405 20 | return res.end('Method not allowed') 21 | } 22 | 23 | let path = require('url').parse(`${req.url}`).pathname || '/' 24 | 25 | try { 26 | if (watcher != null && path == '/_events') { 27 | res.setHeader('Content-Type', 'text/event-stream') 28 | /** 29 | * @param {string} file 30 | * @return {void} 31 | */ 32 | const listener = file => { 33 | if (VERBOSE) console.log('CHANGE', `/${file}`) 34 | res.write(`event: change\ndata: ${file}\n\n`) 35 | } 36 | watcher.addListener('change', listener) 37 | const interval = setInterval(() => { 38 | res.write('event: ping\ndata: ping!\n\n') 39 | }, 10000) 40 | req.on('close', () => { 41 | watcher.removeListener('change', listener) 42 | clearInterval(interval) 43 | }) 44 | return res.write('event: ping\ndata: ping!\n\n') 45 | } 46 | 47 | path = rewriteDir(path) 48 | 49 | const ignored = IGNORE.some(ignore => { 50 | ignore = join('/', ignore, '/') 51 | return path.substr(0, ignore.length) == ignore 52 | }) 53 | 54 | const convert = EXTS.includes(extname(path)) 55 | 56 | if (!ignored && convert) { 57 | const exts = EXTS.filter(ext => ext != extname(path)) 58 | for (const ext of [extname(path), ...exts]) { 59 | const {dir, name} = parse(path) 60 | const file = format({dir, name, ext}) 61 | const mtime = await stat(join(ROOT, file)) 62 | .then(stats => (stats.isFile() ? stats.mtime : undefined)) 63 | .catch(() => undefined) 64 | if (mtime) { 65 | const entry = CACHE.get(file) 66 | res.setHeader('Content-Type', 'text/javascript') 67 | if (entry && +entry.mtime == +mtime) { 68 | if (VERBOSE) console.log('200', file, '(cache)') 69 | return res.end(entry.code) 70 | } 71 | else { 72 | const result = await transformFileAsync(join(ROOT, file), { 73 | plugins: [[require('babel-plugin-bare-import-rewrite'), {extensions: EXTS}]], 74 | cwd: ROOT, 75 | }) 76 | if (!result) throw new Error('Babel transformation failed') 77 | CACHE.set(file, {code: `${result.code}`, mtime}) 78 | if (VERBOSE) console.log('200', file, '(transform)') 79 | return res.end(result.code) 80 | } 81 | } 82 | } 83 | } 84 | 85 | const exists = await stat(join(ROOT, path)) 86 | .then(stats => stats.isFile()) 87 | .catch(() => false) 88 | 89 | if (exists) { 90 | const mimetype = mime.getType(path) 91 | if (mimetype != null) res.setHeader('Content-Type', mimetype) 92 | if (VERBOSE) console.log('200', path) 93 | return createReadStream(join(ROOT, path)).pipe(res) 94 | } 95 | 96 | // app index fallback 97 | if (path.match(/^[/\w-]+$/)) { 98 | res.setHeader('Content-Type', 'text/html') 99 | let src = await readFile(join(ROOT, 'index.html')) 100 | if (watcher != null) 101 | src = src.replace( 102 | /<\/body>/i, 103 | ``.replace(/\s+/g, ' '), 111 | ) 112 | if (VERBOSE) console.log('200', path, '(index)') 113 | return res.end(src) 114 | } 115 | 116 | if (VERBOSE) console.log('404', path) 117 | res.setHeader('Content-Type', 'text/plain; charset=UTF-8') 118 | res.statusCode = 404 119 | return res.end('Not found') 120 | } 121 | catch (err) { 122 | console.error('500', path, '-', err.message) 123 | res.setHeader('Content-Type', 'text/plain; charset=UTF-8') 124 | res.statusCode = 500 125 | res.end(stripAnsi(err.message)) 126 | } 127 | }) 128 | 129 | server.listen(8000, () => { 130 | console.log(`Server running at http://localhost:${PORT}`) 131 | }) 132 | --------------------------------------------------------------------------------