├── .gitignore ├── package.json ├── LICENSE ├── index.js ├── README.md └── moduleserver.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | .tern-* 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "esmoduleserve", 3 | "version": "0.3.1", 4 | "description": "Serve ES modules over HTTP, rewriting imports", 5 | "main": "index.js", 6 | "bin": { 7 | "esmoduleserve": "./index.js" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/marijnh/esmoduleserve.git" 12 | }, 13 | "keywords": [ 14 | "module", 15 | "es module", 16 | "http", 17 | "development", 18 | "dev server" 19 | ], 20 | "author": "Marijn Haverbeke ", 21 | "license": "MIT", 22 | "dependencies": { 23 | "acorn": "^8.0.4", 24 | "acorn-walk": "^8.0.0", 25 | "import-meta-resolve": "^4.2.0", 26 | "serve-static": "^1.14.1" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2020 by Marijn Haverbeke 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const ModuleServer = require("./moduleserver") 4 | const path = require("path") 5 | 6 | let host = "localhost", port = 8080, dir = ".", prefix = null, maxDepth = 1 7 | 8 | function usage() { 9 | console.log("Usage: esmoduleserve [--port port] [--host host] [--depth n] [--prefix prefix] [dir]") 10 | process.exit(1) 11 | } 12 | 13 | for (var i = 2; i < process.argv.length; i++) { 14 | let arg = process.argv[i], next = process.argv[i + 1] 15 | if (arg == "--port" && next) { port = +next; i++ } 16 | else if (arg == "--host" && next) { host = next; i++ } 17 | else if (arg == "--prefix" && next) { prefix = next; i++ } 18 | else if (arg == "--depth" && /^\d+$/.test(next)) { maxDepth = +next; i++ } 19 | else if (dir == "." && arg[0] != "-") dir = arg 20 | else usage() 21 | } 22 | 23 | // The root directory being served. 24 | const root = path.resolve(dir) 25 | 26 | const static = require("serve-static")(root) 27 | const moduleServer = new ModuleServer({root, maxDepth, prefix}).handleRequest 28 | 29 | // Create the server that listens to HTTP requests 30 | // and returns module contents. 31 | require("http").createServer((req, resp) => { 32 | if (moduleServer(req, resp)) return 33 | static(req, resp, function next(err) { 34 | resp.statusCode = 404 35 | resp.end('Not found') 36 | }) 37 | }).listen(port, host) 38 | 39 | console.log("Module server listening on http://" + host + ":" + port) 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # esmoduleserve 2 | 3 | This is a shim HTTP server for directly running ES6 modules with 4 | non-precise import targets in your browser (without a bundling step). 5 | 6 | It acts as a regular file server for a given directory, but exposes an 7 | extra top-level path, `/_m/`, to serve rewritten modules relative to 8 | that directory. Any file requested through this path will have its 9 | imports (and re-exports) rewritten to point at precise resolved 10 | scripts paths, referenced through `/_m/`. 11 | 12 | Resolution is done via the [node 13 | algorithm](https://www.npmjs.com/package/resolve), but letting 14 | `"module"` or `"jsnext"` fields in package.json take precedence over 15 | `"main"`. 16 | 17 | If some of the dependencies you load through this don't provide ES 18 | module files, you are likely to find an error about a missing import 19 | on your devtools console. 20 | 21 | You can specify module files from parent directories of the served 22 | directory using `/__` to stand in for `/..` in a `/_m/` path. By 23 | default, to avoid accidentally serving things you don't want to 24 | expose, this is only allowed one parent directory deep. 25 | 26 | ## Usage 27 | 28 | You run the server for a given directory... 29 | 30 | esmoduleserve demo/ --port 8080 31 | 32 | It will start up an HTTP server on the given port, serving the content 33 | of the `demo` directory statically. If there's a module called 34 | `demo.js` in this directory, you can load it in an HTML file with a 35 | script tag like this: 36 | 37 | 38 | 39 | The options recognized by the command-line server are: 40 | 41 | * **`--port`** to specify a TCP port to listen on. Defaults to 8080. 42 | 43 | * **`--host`** to specify a hostname to listen on. Defaults to 44 | `"localhost"`. 45 | 46 | * **`--depth`** to specify how many parent directories should be 47 | accessible. Defaults to 1. 48 | 49 | * **`--prefix`** to specify an alternative URL prefix for module 50 | script URLs. Defaults to `"_m"`. 51 | 52 | The `moduleserver.js` file exports this functionality as HTTP 53 | middleware. Usage looks something like: 54 | 55 | const {ModuleServer} = require("esmoduleserve/moduleserver") 56 | const moduleServer = new ModuleServer({root: "/some/path", 57 | maxDepth: 2, 58 | prefix: "_m"}) 59 | 60 | // In a server function 61 | if (moduleServer.handleRequest(req, resp)) return 62 | 63 | The `handleRequest` method handles only requests whose path starts 64 | with the prefix. It returns true for such requests. 65 | 66 | ## Source 67 | 68 | This code is open-source under an MIT license. If you want to 69 | contribute, create pull requests 70 | [on GitHub](https://github.com/marijnh/esmoduleserve/). 71 | -------------------------------------------------------------------------------- /moduleserver.js: -------------------------------------------------------------------------------- 1 | const pth = require("path"), fs = require("fs") 2 | const resolve = require("import-meta-resolve") 3 | const {parse: parseURL} = require("url") 4 | const crypto = require("crypto") 5 | const acorn = require("acorn"), walk = require("acorn-walk") 6 | 7 | class Cached { 8 | constructor(content, mimetype) { 9 | this.content = content 10 | this.headers = { 11 | "content-type": mimetype + "; charset=utf-8", 12 | "etag": '"' + hash(content) + '"' 13 | } 14 | } 15 | } 16 | 17 | class ModuleServer { 18 | constructor(options) { 19 | this.root = unwin(options.root) 20 | this.maxDepth = options.maxDepth == null ? 1 : options.maxDepth 21 | this.prefix = options.prefix || "_m" 22 | this.prefixTest = new RegExp(`^/${this.prefix}/(.*)`) 23 | this.transform = options.transform || ((_, c) => c) 24 | if (this.root.charAt(this.root.length - 1) != "/") this.root += "/" 25 | // Maps from paths (relative to root dir) to cache entries 26 | this.cache = Object.create(null) 27 | this.handleRequest = this.handleRequest.bind(this) 28 | } 29 | 30 | handleRequest(req, resp) { 31 | let url = parseURL(req.url) 32 | let handle = this.prefixTest.exec(url.pathname) 33 | if (!handle) return false 34 | 35 | let send = (status, text, headers) => { 36 | let hds = {"access-control-allow-origin": "*", 37 | "x-request-url": req.url} 38 | if (!headers || typeof headers == "string") hds["content-type"] = headers || "text/plain" 39 | else Object.assign(hds, headers) 40 | resp.writeHead(status, hds) 41 | resp.end(text) 42 | } 43 | 44 | // Modules paths in URLs represent "up one directory" as "__". 45 | // Convert them to ".." for filesystem path resolution. 46 | let path = undash(handle[1]) 47 | let cached = this.cache[path] 48 | if (!cached) { 49 | if (countParentRefs(path) > this.maxDepth) { send(403, "Access denied"); return true } 50 | let fullPath = unwin(pth.resolve(this.root, path)), code 51 | try { code = fs.readFileSync(fullPath, "utf8") } 52 | catch { send(404, "Not found"); return true } 53 | if (/\.map$/.test(fullPath)) { 54 | cached = this.cache[path] = new Cached(code, "application/json") 55 | } else { 56 | let {code: resolvedCode, error} = this.resolveImports(fullPath, this.transform(fullPath, code)) 57 | if (error) { send(500, error); return true } 58 | cached = this.cache[path] = new Cached(resolvedCode, "application/javascript") 59 | } 60 | // Drop cache entry when the file changes. 61 | let watching = fs.watch(fullPath, () => { 62 | watching.close() 63 | this.cache[path] = null 64 | }) 65 | } 66 | let noneMatch = req.headers["if-none-match"] 67 | if (noneMatch && noneMatch.indexOf(cached.headers.etag) > -1) { send(304, null); return true } 68 | send(200, cached.content, cached.headers) 69 | return true 70 | } 71 | 72 | // Resolve a module path to a relative filepath where 73 | // the module's file exists. 74 | resolveModule(srcPath, path) { 75 | let resolved 76 | try { resolved = resolveMod(path, srcPath) } 77 | catch(e) { return {error: e.toString()} } 78 | 79 | // Builtin modules resolve to strings like "fs". Try again with 80 | // slash which makes it possible to locally install an equivalent. 81 | if (resolved.indexOf("/") == -1) { 82 | try { resolved = resolveMod(path + "/", srcPath) } 83 | catch(e) { return {error: e.toString()} } 84 | } 85 | 86 | return {path: "/" + this.prefix + "/" + unwin(pth.relative(this.root, resolved))} 87 | } 88 | 89 | resolveImports(srcPath, code) { 90 | let patches = [], ast 91 | try { ast = acorn.parse(code, {sourceType: "module", ecmaVersion: "latest"}) } 92 | catch(error) { return {error: error.toString()} } 93 | let patchSrc = (node) => { 94 | if (!node.source) return 95 | let orig = (0, eval)(code.slice(node.source.start, node.source.end)) 96 | let {error, path} = this.resolveModule(srcPath, orig) 97 | if (error) return {error} 98 | patches.push({from: node.source.start, to: node.source.end, text: JSON.stringify(dash(path))}) 99 | } 100 | walk.simple(ast, { 101 | ExportNamedDeclaration: patchSrc, 102 | ImportDeclaration: patchSrc, 103 | ImportExpression: node => { 104 | if (node.source.type == "Literal") { 105 | let {error, path} = this.resolveModule(srcPath, node.source.value) 106 | if (!error) 107 | patches.push({from: node.source.start, to: node.source.end, text: JSON.stringify(dash(path))}) 108 | } 109 | } 110 | }) 111 | for (let patch of patches.sort((a, b) => b.from - a.from)) 112 | code = code.slice(0, patch.from) + patch.text + code.slice(patch.to) 113 | return {code} 114 | } 115 | } 116 | module.exports = ModuleServer 117 | 118 | function dash(path) { return path.replace(/(^|\/)\.\.(?=$|\/)/g, "$1__") } 119 | function undash(path) { return path.replace(/(^|\/)__(?=$|\/)/g, "$1..") } 120 | 121 | const unwin = pth.sep == "\\" ? s => s.replace(/\\/g, "/") : s => s 122 | 123 | function resolveMod(path, base) { 124 | let url = resolve.resolve(path, "file://" + base) 125 | return fs.realpathSync(url.slice(7)) 126 | } 127 | 128 | function hash(str) { 129 | let sum = crypto.createHash("sha1") 130 | sum.update(str) 131 | return sum.digest("hex") 132 | } 133 | 134 | function countParentRefs(path) { 135 | let re = /(^|\/)\.\.(?=\/|$)/g, count = 0 136 | while (re.exec(path)) count++ 137 | return count 138 | } 139 | --------------------------------------------------------------------------------