├── .editorconfig ├── .gitignore ├── LICENSE ├── README.md ├── assets ├── 404.html ├── __stencil-dev-server__ │ └── favicon.ico └── index.html ├── bin └── stencil-dev-server ├── circle.yml ├── package-lock.json ├── package.json ├── preprocessor.js ├── src ├── __tests__ │ ├── empty-html5.ts │ ├── empty-html5 │ │ └── stencil.config.js │ ├── empty.ts │ ├── empty │ │ └── stencil.config.js │ ├── no-html5.ts │ ├── no-html5 │ │ ├── alert.js │ │ ├── index.html │ │ └── stencil.config.js │ ├── simple.ts │ └── simple │ │ ├── alert.js │ │ ├── index.html │ │ ├── stencil.config.js │ │ └── stencil.config.ssl.js ├── definitions.ts ├── index.ts ├── middlewares.ts ├── promisify.ts └── utils.ts ├── tsconfig.json └── types ├── devcert-san.d.ts ├── ecstatic.d.ts └── tiny-lr.d.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | insert_final_newline = false 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | dist/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Josh Thomas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm][npm-badge]][npm-badge-url] 2 | [![Build Status][circle-badge]][circle-badge-url] 3 | 4 | ## Deprecated: The stencil-dev-server is now integrated into @stencil/core. 5 | 6 | As of Stencil `0.10.0`, please use the `--serve` argument to enable Stencil's integrated dev-server, which replaces this project. 7 | 8 | # Stencil Dev Server 9 | 10 | This is a very simple http-server with a filewatcher and livereload built in. This server 11 | was built with the purpose of making it easy to develop stencil apps and components, but it will work 12 | with about any dev workflow. 13 | 14 | Just provide a directory. 15 | 16 | ``` 17 | stencil-dev-server --root public 18 | ``` 19 | 20 | There are a number of options available, but all have sane defaults. 21 | 22 | - **--root** 23 | - The directory that should be watched and served 24 | - It defaults to the current directory that the command was executed from. 25 | - **--watchGlob** 26 | - The pattern of files to watch in the root directory for changes. 27 | - The glob defaults to **\*\*/\*\***. 28 | - **--address** 29 | - The ip address that the server should listen to. 30 | - Defaults to *0.0.0.0*. Point your browser to localhost. 31 | - **--httpPort** 32 | - The port that the http server should use. If the number provided is in use it will choose another. 33 | - Defaults to *3333*. 34 | - **--liveReloadPort** 35 | - The port that the live-reload server should use. If the number provided is in use it will choose another. 36 | - Defaults to *35729*. 37 | - **--additionalJsScripts** 38 | - A comma separated list of javascript files that you would like appended to all html page body tags. This allows you to expand the dev server to do additional behaviors. 39 | - **--config** 40 | - The path to a config file for the dev server. This allows you to keep a specific set of default parameters in a configuration file. 41 | - Defaults to *./stencil.config.js* 42 | - **--ssl** 43 | - Enables https for development server with self signed SSL certificate 44 | - Defaults to false 45 | - **--no-open** 46 | - Disables automatically opening a browser. 47 | 48 | Config File Structure 49 | 50 | ```js 51 | exports.devServer = { 52 | root: './', 53 | additionalJSScripts: [ 54 | 'http://localhost:3529/debug.js', 55 | './scripts/additionalDebug.js' 56 | ], 57 | watchGlob: '**/*' 58 | }; 59 | ``` 60 | 61 | [npm-badge]: https://img.shields.io/npm/v/@stencil/dev-server.svg?style=flat-square 62 | [npm-badge-url]: https://www.npmjs.com/package/@stencil/dev-server 63 | [circle-badge]: https://circleci.com/gh/ionic-team/stencil-dev-server.svg?style=shield 64 | [circle-badge-url]: https://circleci.com/gh/ionic-team/stencil-dev-server 65 | -------------------------------------------------------------------------------- /assets/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 404: Resource not found 7 | 8 | 9 | 10 |
11 |

~{linked-path}

12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /assets/__stencil-dev-server__/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionic-team/stencil-dev-server/a85c67d356efe3403ab778bd934bfa36d4b0f84d/assets/__stencil-dev-server__/favicon.ico -------------------------------------------------------------------------------- /assets/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | listing directory {directory} 7 | 8 | 32 | 33 | 34 |
35 |

~{linked-path}

36 | {files} 37 |
38 | 39 | 40 | -------------------------------------------------------------------------------- /bin/stencil-dev-server: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 'use strict'; 3 | 4 | process.title = 'stencil-dev-server'; 5 | process.on('unhandledRejection', function(r) { console.error(r) }); 6 | process.env.TINY_STATIC_LR_BIN = __filename; 7 | 8 | let cmdArgs = process.argv; 9 | const npmRunArgs = process.env.npm_config_argv; 10 | if (npmRunArgs) { 11 | cmdArgs = cmdArgs.concat(JSON.parse(npmRunArgs).original); 12 | } 13 | 14 | const server = require('../dist'); 15 | 16 | server.run(cmdArgs); 17 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 6.9.0 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@stencil/dev-server", 3 | "version": "0.0.19-1", 4 | "description": "Tiny LiveReload server that watches a single directory", 5 | "main": "dist/index.js", 6 | "bin": { 7 | "stencil-dev-server": "./bin/stencil-dev-server" 8 | }, 9 | "scripts": { 10 | "dev": "tsc -w -p .", 11 | "build": "tsc -p .", 12 | "test": "jest --runInBand", 13 | "deploy": "npm run build && np" 14 | }, 15 | "files": [ 16 | "assets/", 17 | "bin/", 18 | "dist/", 19 | "LICENSE", 20 | "README.md" 21 | ], 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/ionic-team/stencil-dev-server.git" 25 | }, 26 | "author": "Ionic Team", 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/ionic-team/stencil-dev-server/issues" 30 | }, 31 | "homepage": "https://github.com/ionic-team/stencil-dev-server#readme", 32 | "dependencies": { 33 | "@ionic/discover": "^0.3.3", 34 | "chokidar": "^1.7.0", 35 | "devcert-san": "^0.3.3", 36 | "ecstatic": "^2.2.1", 37 | "opn": "^5.1.0", 38 | "tiny-lr": "^1.0.5" 39 | }, 40 | "devDependencies": { 41 | "@types/chokidar": "^1.7.0", 42 | "@types/jest": "^21.1.2", 43 | "@types/opn": "^3.0.28", 44 | "@types/supertest": "^2.0.3", 45 | "jest": "^21.2.1", 46 | "np": "^2.16.0", 47 | "supertest": "^3.0.0", 48 | "typescript": "^2.4.1" 49 | }, 50 | "jest": { 51 | "moduleFileExtensions": [ 52 | "ts", 53 | "tsx", 54 | "js" 55 | ], 56 | "transform": { 57 | "^.+\\.(ts|tsx)$": "/preprocessor.js" 58 | }, 59 | "testMatch": [ 60 | "**/__tests__/*.(ts|tsx|js)" 61 | ] 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /preprocessor.js: -------------------------------------------------------------------------------- 1 | const tsc = require('typescript'); 2 | const tsConfig = require('./tsconfig.json'); 3 | 4 | module.exports = { 5 | process: function (src, path) { 6 | if (path.endsWith('.ts') || path.endsWith('.tsx')) { 7 | return tsc.transpile(src, tsConfig.compilerOptions, path, []); 8 | } 9 | return src; 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /src/__tests__/empty-html5.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as request from 'supertest'; 4 | import * as path from 'path'; 5 | import { run as createDevServer, StencilDevServer } from '..'; 6 | 7 | describe('GET Collection', async function () { 8 | let cmdArgs: string[]; 9 | let devServer: StencilDevServer; 10 | 11 | beforeAll(async function () { 12 | cmdArgs = [ 13 | '--config', path.join(__dirname, './empty-html5/stencil.config.js'), 14 | '--no-open', 15 | ]; 16 | devServer = await createDevServer(cmdArgs); 17 | }); 18 | 19 | afterAll(async function () { 20 | await devServer.close(); 21 | }); 22 | 23 | describe('Scenarios for an empty directory with html5mode true', function () { 24 | it('should return 200 and directory contents', async function () { 25 | const response = await request(devServer.httpServer).get('/'); 26 | expect(response.status).toBe(200); 27 | expect(response.text).toContain('~'); 28 | }); 29 | 30 | it('should return 404 for files that do not exist', async function () { 31 | const response = await request(devServer.httpServer).get('/red.jpg'); 32 | expect(response.status).toBe(404); 33 | }); 34 | 35 | it('should return 404 for html files that do not exist', async function () { 36 | const response = await request(devServer.httpServer).get('/red.html'); 37 | expect(response.status).toBe(404); 38 | }); 39 | 40 | it('should return 404 for folders that do not exist', async function () { 41 | const response = await request(devServer.httpServer).get('/red'); 42 | expect(response.status).toBe(404); 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/__tests__/empty-html5/stencil.config.js: -------------------------------------------------------------------------------- 1 | exports.devServer = { 2 | watchGlob: '**/*.js', 3 | html5mode: true, 4 | root: __dirname, 5 | }; 6 | -------------------------------------------------------------------------------- /src/__tests__/empty.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as request from 'supertest'; 4 | import * as path from 'path'; 5 | import { run as createDevServer, StencilDevServer } from '..'; 6 | 7 | describe('GET Collection', async function () { 8 | let cmdArgs: string[]; 9 | let devServer: StencilDevServer; 10 | 11 | beforeAll(async function () { 12 | cmdArgs = [ 13 | '--config', path.join(__dirname, './empty/stencil.config.js'), 14 | '--no-open', 15 | ]; 16 | devServer = await createDevServer(cmdArgs); 17 | }); 18 | 19 | afterAll(async function () { 20 | await devServer.close(); 21 | }); 22 | 23 | describe('Scenarios for an empty directory with html5mode false', function () { 24 | it('should return 200 and directory contents', async function () { 25 | const response = await request(devServer.httpServer).get('/'); 26 | expect(response.status).toBe(200); 27 | expect(response.text).toContain('~'); 28 | }); 29 | 30 | it('should return 404 for files that do not exist', async function () { 31 | const response = await request(devServer.httpServer).get('/red.jpg'); 32 | expect(response.status).toBe(404); 33 | }); 34 | 35 | it('should return 404 for html files that do not exist', async function () { 36 | const response = await request(devServer.httpServer).get('/red.html'); 37 | expect(response.status).toBe(404); 38 | }); 39 | 40 | it('should return 404 for folders that do not exist', async function () { 41 | const response = await request(devServer.httpServer).get('/red'); 42 | expect(response.status).toBe(404); 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/__tests__/empty/stencil.config.js: -------------------------------------------------------------------------------- 1 | exports.devServer = { 2 | watchGlob: '**/*.js', 3 | html5mode: false, 4 | root: __dirname, 5 | }; 6 | -------------------------------------------------------------------------------- /src/__tests__/no-html5.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as request from 'supertest'; 4 | import * as path from 'path'; 5 | import { run as createDevServer, StencilDevServer } from '..'; 6 | 7 | describe('GET Collection', async function () { 8 | let cmdArgs: string[]; 9 | let devServer: StencilDevServer; 10 | 11 | beforeAll(async function () { 12 | cmdArgs = [ 13 | '--config', path.join(__dirname, './no-html5/stencil.config.js'), 14 | '--no-open', 15 | ]; 16 | devServer = await createDevServer(cmdArgs); 17 | }); 18 | 19 | afterAll(async function () { 20 | await devServer.close(); 21 | }); 22 | 23 | describe('Scenarios for html5mode false', function () { 24 | it('should return 200 for index', async function () { 25 | const response = await request(devServer.httpServer).get('/'); 26 | expect(response.status).toBe(200); 27 | expect(response.header['content-type']).toEqual('text/html'); 28 | expect(response.header['expires']).toEqual('0'); 29 | expect(response.header['cache-control']).toEqual( 30 | 'no-cache, no-store, must-revalidate, max-age=0' 31 | ); 32 | }); 33 | 34 | it('should return 404 for files that do not exist', async function () { 35 | const response = await request(devServer.httpServer).get('/red.jpg'); 36 | expect(response.status).toBe(404); 37 | }); 38 | 39 | it('should return 404 for html files that do not exist', async function () { 40 | const response = await request(devServer.httpServer).get('/red.html'); 41 | expect(response.status).toBe(404); 42 | }); 43 | 44 | it('should return 404 for folders that do not exist', async function () { 45 | const response = await request(devServer.httpServer).get('/red'); 46 | expect(response.status).toBe(404); 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/__tests__/no-html5/alert.js: -------------------------------------------------------------------------------- 1 | console.log('alert'); 2 | -------------------------------------------------------------------------------- /src/__tests__/no-html5/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | there man wa test 5 | 6 | -------------------------------------------------------------------------------- /src/__tests__/no-html5/stencil.config.js: -------------------------------------------------------------------------------- 1 | exports.devServer = { 2 | watchGlob: '**/*.js', 3 | html5mode: false, 4 | root: __dirname, 5 | }; 6 | -------------------------------------------------------------------------------- /src/__tests__/simple.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as request from 'supertest'; 4 | import * as path from 'path'; 5 | import { run as createDevServer, StencilDevServer } from '..'; 6 | 7 | describe('GET Collection', async function () { 8 | let cmdArgs: string[]; 9 | let devServer: StencilDevServer; 10 | 11 | describe('Scenarios for html5mode true', function () { 12 | 13 | beforeAll(async function () { 14 | cmdArgs = [ 15 | '--config', path.join(__dirname, './simple/stencil.config.js'), 16 | '--no-open', 17 | ]; 18 | devServer = await createDevServer(cmdArgs); 19 | }); 20 | 21 | afterAll(async function () { 22 | await devServer.close(); 23 | }); 24 | 25 | it('should return 200 for index', async function () { 26 | const response = await request(devServer.httpServer).get('/'); 27 | expect(response.status).toBe(200); 28 | expect(response.header['content-type']).toEqual('text/html'); 29 | expect(response.header['expires']).toEqual('0'); 30 | expect(response.header['cache-control']).toEqual( 31 | 'no-cache, no-store, must-revalidate, max-age=0' 32 | ); 33 | expect(response.text).toContain('/__stencil-dev-server__/js_includes/alert.js'); 34 | expect(response.text).toContain('http://localhost:4444/red/blue.js'); 35 | }); 36 | 37 | it('should return 200 and index file for files that do not exist', async function () { 38 | const response = await request(devServer.httpServer).get('/red.jpg'); 39 | expect(response.status).toBe(404); 40 | }); 41 | 42 | it('should return 200 for js_incldues files that do exist', async function () { 43 | const response = await request(devServer.httpServer).get('/__stencil-dev-server__/js_includes/alert.js'); 44 | expect(response.status).toBe(200); 45 | }); 46 | 47 | it('should return 404 for js_incldues files that do not exist', async function () { 48 | const response = await request(devServer.httpServer).get('/__stencil-dev-server__/js_includes/bad-file.js'); 49 | expect(response.status).toBe(404); 50 | }); 51 | 52 | it('should return 200 and index file for html files that do not exist', async function () { 53 | const response = await request(devServer.httpServer).get('/red.html'); 54 | expect(response.status).toBe(200); 55 | expect(response.header['content-type']).toEqual('text/html'); 56 | }); 57 | 58 | it('should return 200 and index file for folders that do not exist', async function () { 59 | const response = await request(devServer.httpServer).get('/red'); 60 | expect(response.status).toBe(200); 61 | expect(response.header['content-type']).toEqual('text/html'); 62 | }); 63 | }); 64 | 65 | describe('Scenarios for html5mode true and SSL set to true', function () { 66 | 67 | beforeAll(async function () { 68 | cmdArgs = [ 69 | '--config', path.join(__dirname, './simple/stencil.config.ssl.js'), 70 | '--no-open', 71 | ]; 72 | devServer = await createDevServer(cmdArgs); 73 | }); 74 | 75 | afterAll(async function () { 76 | await devServer.close(); 77 | }); 78 | 79 | it('should return 200 for index', async function () { 80 | const response = await request(devServer.httpServer).get('/'); 81 | expect(response.status).toBe(200); 82 | expect(response.header['content-type']).toEqual('text/html'); 83 | expect(response.header['expires']).toEqual('0'); 84 | expect(response.header['cache-control']).toEqual( 85 | 'no-cache, no-store, must-revalidate, max-age=0' 86 | ); 87 | expect(response.text).toContain('/__stencil-dev-server__/js_includes/alert.js'); 88 | expect(response.text).toContain('https://localhost:4444/red/blue.js'); 89 | }); 90 | 91 | it('should return 200 and index file for files that do not exist', async function () { 92 | const response = await request(devServer.httpServer).get('/red.jpg'); 93 | expect(response.status).toBe(404); 94 | }); 95 | 96 | it('should return 200 for js_incldues files that do exist', async function () { 97 | const response = await request(devServer.httpServer).get('/__stencil-dev-server__/js_includes/alert.js'); 98 | expect(response.status).toBe(200); 99 | }); 100 | 101 | it('should return 404 for js_incldues files that do not exist', async function () { 102 | const response = await request(devServer.httpServer).get('/__stencil-dev-server__/js_includes/bad-file.js'); 103 | expect(response.status).toBe(404); 104 | }); 105 | 106 | it('should return 200 and index file for html files that do not exist', async function () { 107 | const response = await request(devServer.httpServer).get('/red.html'); 108 | expect(response.status).toBe(200); 109 | expect(response.header['content-type']).toEqual('text/html'); 110 | }); 111 | 112 | it('should return 200 and index file for folders that do not exist', async function () { 113 | const response = await request(devServer.httpServer).get('/red'); 114 | expect(response.status).toBe(200); 115 | expect(response.header['content-type']).toEqual('text/html'); 116 | }); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /src/__tests__/simple/alert.js: -------------------------------------------------------------------------------- 1 | console.log('alert'); 2 | -------------------------------------------------------------------------------- /src/__tests__/simple/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | there man wa test 5 | 6 | -------------------------------------------------------------------------------- /src/__tests__/simple/stencil.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | exports.devServer = { 3 | additionalJsScripts: [path.join(__dirname, '/alert.js'), 'http://localhost:4444/red/blue.js'], 4 | watchGlob: '**/*.js', 5 | html5mode: true, 6 | root: __dirname 7 | }; 8 | -------------------------------------------------------------------------------- /src/__tests__/simple/stencil.config.ssl.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; 3 | exports.devServer = { 4 | additionalJsScripts: [path.join(__dirname, '/alert.js'), 'https://localhost:4444/red/blue.js'], 5 | watchGlob: '**/*.js', 6 | html5mode: true, 7 | root: __dirname, 8 | ssl : true 9 | }; 10 | -------------------------------------------------------------------------------- /src/definitions.ts: -------------------------------------------------------------------------------- 1 | export interface InputOptions { 2 | [key: string]: InputType 3 | } 4 | export type InputType = { 5 | default: string | number | boolean; 6 | type: String | Number | Boolean; 7 | }; 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fs from 'fs'; 3 | import * as url from 'url'; 4 | import * as tinylr from 'tiny-lr'; 5 | import * as ecstatic from 'ecstatic'; 6 | import * as opn from 'opn'; 7 | import * as http from 'http'; 8 | import * as https from 'https'; 9 | import { watch, FSWatcher } from 'chokidar'; 10 | import { findClosestOpenPort, parseOptions, parseConfigFile, 11 | getRequestedPath, getFileFromPath, fsStatPr ,getSSL} from './utils'; 12 | import { serveHtml, serveDirContents, sendError, sendFile } from './middlewares'; 13 | import { newSilentPublisher } from '@ionic/discover'; 14 | 15 | 16 | 17 | const RESERVED_STENCIL_PATH = '/__stencil-dev-server__'; 18 | 19 | const optionInfo = { 20 | root: { 21 | default: 'www', 22 | type: String 23 | }, 24 | verbose: { 25 | default: false, 26 | type: Boolean 27 | }, 28 | html5mode: { 29 | default: true, 30 | type: Boolean 31 | }, 32 | watchGlob: { 33 | default: '**/*', 34 | type: String 35 | }, 36 | address: { 37 | default: '0.0.0.0', 38 | type: String 39 | }, 40 | httpPort: { 41 | default: 3333, 42 | type: Number 43 | }, 44 | liveReloadPort: { 45 | default: 35729, 46 | type: Number 47 | }, 48 | additionalJsScripts: { 49 | default: '', 50 | type: String 51 | }, 52 | config: { 53 | default: './stencil.config.js', 54 | type: String 55 | }, 56 | ssl: { 57 | default: false, 58 | type: Boolean 59 | } 60 | } 61 | 62 | export interface StencilDevServer { 63 | httpServer: http.Server | https.Server, 64 | fileWatcher: FSWatcher, 65 | tinyLrServer: tinylr.server, 66 | close: () => Promise 67 | } 68 | 69 | export async function run(argv: string[]) { 70 | const cliDefaultedOptions = parseOptions(optionInfo, argv); 71 | cliDefaultedOptions.additionalJsScripts = cliDefaultedOptions.additionalJsScripts 72 | .split(',') 73 | .filter((name: string) => !!name); 74 | const isVerbose = cliDefaultedOptions.verbose; 75 | const configOptions = await parseConfigFile(process.cwd(), cliDefaultedOptions.config); 76 | const options = Object.keys(cliDefaultedOptions).reduce((options, optionName) => { 77 | const newValue = (configOptions[optionName] == null) ? 78 | cliDefaultedOptions[optionName] : 79 | configOptions[optionName]; 80 | options[optionName] = newValue; 81 | return options; 82 | }, <{ [key: string]: any }>{}); 83 | 84 | const [ foundHttpPort, foundLiveReloadPort ] = await Promise.all([ 85 | findClosestOpenPort(options.address, options.httpPort), 86 | findClosestOpenPort(options.address, options.liveReloadPort), 87 | ]); 88 | 89 | const protocol:string = options.ssl ? 'https' : 'http'; 90 | log(isVerbose, `Will serve requests using : ${protocol}`); 91 | 92 | const wwwRoot = path.resolve(options.root); 93 | const browserUrl = getAddressForBrowser(options.address); 94 | const [ tinyLrServer, lrScriptLocation, emitLiveReloadUpdate ] = await createLiveReload(foundLiveReloadPort, options.address, wwwRoot , options.ssl); 95 | 96 | const jsScriptLocations: string[] = options.additionalJsScripts 97 | .map((filePath: string) => filePath.trim()) 98 | .concat(lrScriptLocation); 99 | 100 | const fileWatcher = createFileWatcher(wwwRoot, options.watchGlob, emitLiveReloadUpdate, isVerbose); 101 | log(isVerbose, `watching: ${wwwRoot} ${options.watchGlob}`); 102 | 103 | const requestHandler = createHttpRequestHandler(wwwRoot, jsScriptLocations, options.html5mode); 104 | 105 | const httpServer = options.ssl ? https.createServer( await getSSL() ,requestHandler).listen(foundHttpPort) 106 | : http.createServer(requestHandler).listen(foundHttpPort); 107 | 108 | log(true, `listening on ${protocol}://${browserUrl}:${foundHttpPort}`); 109 | log(isVerbose, `serving: ${wwwRoot}`); 110 | 111 | if (argv.indexOf('--no-open') === -1) { 112 | opn(`${protocol}://${browserUrl}:${foundHttpPort}`); 113 | } 114 | 115 | if (argv.indexOf('--broadcast') >= 0) { 116 | log(isVerbose, 'publishing broadcast'); 117 | newSilentPublisher('devapp', 'stencil-dev', foundHttpPort); 118 | } 119 | 120 | async function close() { 121 | fileWatcher.close(); 122 | tinyLrServer.close(); 123 | await new Promise((resolve, reject) => { 124 | httpServer.close((err: Error) => { 125 | if (err) { 126 | reject(err); 127 | } 128 | resolve(); 129 | }); 130 | }); 131 | } 132 | 133 | process.once('SIGINT', async () => { 134 | await close(); 135 | process.exit(0); 136 | }); 137 | 138 | return { 139 | httpServer, 140 | fileWatcher, 141 | tinyLrServer, 142 | close 143 | } as StencilDevServer; 144 | } 145 | 146 | function createHttpRequestHandler(wwwDir: string, jsScriptsList: string[], html5mode: boolean) { 147 | const jsScriptsMap = jsScriptsList.reduce((map, fileUrl: string): { [key: string ]: string } => { 148 | const urlParts = url.parse(fileUrl); 149 | if (urlParts.host) { 150 | map[fileUrl] = fileUrl; 151 | } else { 152 | const baseFileName = path.basename(fileUrl); 153 | map[path.join(RESERVED_STENCIL_PATH, 'js_includes', baseFileName)] = path.resolve(process.cwd(), fileUrl); 154 | } 155 | return map; 156 | }, <{ [key: string ]: string }>{}); 157 | 158 | const staticFileMiddleware = ecstatic({ root: wwwDir, cache: 0 }); 159 | const devServerFileMiddleware = ecstatic({ root: path.resolve(__dirname, '..', 'assets') }); 160 | const sendHtml = serveHtml(wwwDir, Object.keys(jsScriptsMap)); 161 | const sendDirectoryContents = serveDirContents(wwwDir); 162 | 163 | return async function(req: http.IncomingMessage, res: http.ServerResponse) { 164 | const reqPath = getRequestedPath(req.url || ''); 165 | const filePath = getFileFromPath(wwwDir, req.url || ''); 166 | let pathStat: fs.Stats; 167 | 168 | const serveIndexFile = async (directory: string) => { 169 | const indexFilePath = path.join(directory, 'index.html'); 170 | let indexFileStat: fs.Stats | undefined; 171 | try { 172 | indexFileStat = await fsStatPr(indexFilePath); 173 | } catch (err) {} 174 | 175 | if (indexFileStat && indexFileStat.isFile()) { 176 | return sendHtml(indexFilePath, req, res); 177 | } 178 | } 179 | 180 | // If the file is a member of the scripts we autoload then serve it 181 | if (jsScriptsMap[(req.url || '')]) { 182 | return sendFile('application/javascript', jsScriptsMap[(req.url || '')], req, res); 183 | } 184 | 185 | // If the request is to a static file that is part of this package 186 | // then just send it on using the static file middleware 187 | if ((req.url || '').startsWith(RESERVED_STENCIL_PATH)) { 188 | return devServerFileMiddleware(req, res); 189 | } 190 | 191 | try { 192 | pathStat = await fsStatPr(filePath); 193 | } catch (err) { 194 | 195 | // File or path does not exist 196 | if (err.code === 'ENOENT' || err.code === 'ENOTDIR') { 197 | if (html5mode && (['.html', ''].indexOf(path.extname(filePath)) !== -1)) { 198 | await serveIndexFile(wwwDir); 199 | if (res.finished) { 200 | return; 201 | } 202 | } 203 | 204 | // The wwwDir index.html file does not exist. 205 | return sendError(404, res, { error: err }); 206 | } 207 | 208 | // No access to the file. 209 | if (err.code === 'EACCES') { 210 | return sendError(403, res, { error: err }); 211 | } 212 | 213 | // Not sure what happened. 214 | return sendError(500, res, { error: err }); 215 | } 216 | 217 | // If this is the first request then try to serve an index.html file in the root dir 218 | if (reqPath === '/') { 219 | await serveIndexFile(wwwDir); 220 | if (res.finished) { 221 | return; 222 | } 223 | } 224 | 225 | 226 | // If the request is to a directory then serve the directory contents 227 | if (pathStat.isDirectory()) { 228 | await serveIndexFile(filePath); 229 | if (res.finished) { 230 | return; 231 | } 232 | 233 | // If the request is to a directory but does not end in slash then redirect to use a slash 234 | if (!reqPath.endsWith('/')) { 235 | res.writeHead(302, { 236 | 'location': reqPath + '/' 237 | }); 238 | return res.end(); 239 | } 240 | 241 | return await sendDirectoryContents(filePath, req, res); 242 | } 243 | 244 | // If the request is to a file and it is an html file then use sendHtml to parse and send on 245 | if (pathStat.isFile() && filePath.endsWith('.html')) { 246 | return await sendHtml(filePath, req, res); 247 | } 248 | if (pathStat.isFile()) { 249 | return staticFileMiddleware(req, res); 250 | } 251 | 252 | // Not sure what you are requesting but lets just send an error 253 | return sendError(415, res, { error: 'Resource requested cannot be served.' }); 254 | } 255 | } 256 | 257 | let timeoutId: NodeJS.Timer; 258 | 259 | function createFileWatcher(wwwDir: string, watchGlob: string, changeCb: Function, isVerbose: boolean) { 260 | const watcher = watch(watchGlob, { 261 | cwd: wwwDir, 262 | ignored: /(^|[\/\\])\../ // Ignore dot files, ie .git 263 | }); 264 | 265 | function fileChanged(filePath: string) { 266 | clearTimeout(timeoutId); 267 | 268 | timeoutId = setTimeout(() => { 269 | log(isVerbose, `[${new Date().toTimeString().slice(0, 8)}] ${filePath} changed`); 270 | changeCb([filePath]); 271 | }, 50); 272 | } 273 | 274 | watcher.on('change', fileChanged); 275 | watcher.on('error', (err: Error) => { 276 | log(true, err.toString()); 277 | }); 278 | 279 | return watcher; 280 | } 281 | 282 | 283 | async function createLiveReload(port: number, address: string, wwwDir: string , ssl: boolean): Promise<[ tinylr.server, string, (changedFile: string[]) => void]> { 284 | 285 | const options:any = ssl ? await getSSL() : {}; 286 | const protocol:string = ssl ? 'https' : 'http'; 287 | 288 | const liveReloadServer = tinylr(options); 289 | liveReloadServer.listen(port,address); 290 | 291 | return [ 292 | liveReloadServer, 293 | `${protocol}://${getAddressForBrowser(address)}:${port}/livereload.js?snipver=1`, 294 | (changedFiles: string[]) => { 295 | liveReloadServer.changed({ 296 | body: { 297 | files: changedFiles.map(changedFile => ( 298 | '/' + path.relative(wwwDir, changedFile) 299 | )) 300 | } 301 | }); 302 | } 303 | ]; 304 | } 305 | 306 | function getAddressForBrowser(ipAddress: string) { 307 | return (ipAddress === '0.0.0.0') ? 'localhost' : ipAddress; 308 | } 309 | 310 | function log(test: boolean, ...args: any[]) { 311 | if (test) { 312 | console.log(...args); 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /src/middlewares.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as url from 'url'; 3 | import * as fs from 'fs'; 4 | import * as http from 'http'; 5 | import { fsReadFilePr, fsReadDirPr, fsStatPr } from './utils'; 6 | 7 | export function serveHtml(wwwDir: string, scriptLocations: string[]) { 8 | return async function(filePath: string, req: http.IncomingMessage, res: http.ServerResponse) { 9 | const indexHtml = await fsReadFilePr(filePath); 10 | const appendString = scriptLocations.map(sl => ``).join('\n'); 11 | const htmlString: string = indexHtml.toString() 12 | .replace( 13 | ``, 14 | `${appendString} 15 | ` 16 | ); 17 | 18 | res.writeHead(200, { 19 | 'Cache-Control': 'no-cache, no-store, must-revalidate, max-age=0', 20 | 'Expires': '0', 21 | 'Content-Type': 'text/html' 22 | }); 23 | res.end(htmlString); 24 | }; 25 | } 26 | 27 | export function serveDirContents(wwwDir: string) { 28 | return async function(dirPath: string, req: http.IncomingMessage, res: http.ServerResponse) { 29 | let files: string[]; 30 | const dirUrl = req.url; 31 | if (!dirUrl) { 32 | return sendError(500, res, { err: 'Somthing is not right' }); 33 | } 34 | try { 35 | files = await fsReadDirPr(dirPath); 36 | } catch(err) { 37 | return sendError(500, res, { err: err}); 38 | } 39 | 40 | const templateSrc = await fsReadFilePr(path.join(__dirname, '..', 'assets', 'index.html')); 41 | if (!templateSrc) { 42 | throw new Error('wait, where is my template src.'); 43 | } 44 | files = files 45 | .filter((fileName) => '.' !== fileName[0]) // remove hidden files 46 | .sort(); 47 | 48 | const fileStats: fs.Stats[] = await Promise.all(files.map((fileName) => 49 | fsStatPr(path.join(dirPath, fileName)) 50 | )); 51 | 52 | if (dirUrl !== '/') { 53 | const dirStat = await fsStatPr(dirPath); 54 | files.unshift('..'); 55 | fileStats.unshift(dirStat); 56 | } 57 | 58 | const fileHtml = files 59 | .map((fileName, index) => { 60 | const isDirectory = fileStats[index].isDirectory(); 61 | return ( 62 | `${isDirectory ? 'd' : '-'} ${fileName}` 65 | ); 66 | }) 67 | .join('
\n'); 68 | 69 | const templateHtml = templateSrc.toString() 70 | .replace('{directory}', dirPath) 71 | .replace('{files}', fileHtml) 72 | .replace('{linked-path}', dirUrl.replace(/\//g,' / ')); 73 | 74 | 75 | res.writeHead(200, { 76 | 'Cache-Control': 'no-cache, no-store, must-revalidate, max-age=0', 77 | 'Expires': '0', 78 | 'Content-Type': 'text/html' 79 | }); 80 | res.end(templateHtml); 81 | } 82 | } 83 | export async function sendFile(contentType: string, filePath: string, req: http.IncomingMessage, res: http.ServerResponse) { 84 | const stat = await fsStatPr(filePath); 85 | 86 | if (!stat.isFile()) { 87 | return sendError(404, res, { error: 'File not found'}); 88 | } 89 | 90 | res.writeHead(200, { 91 | 'Cache-Control': 'no-cache, no-store, must-revalidate, max-age=0', 92 | 'Expires': '0', 93 | 'Content-Type': contentType, 94 | 'Content-Length': stat.size 95 | }); 96 | 97 | fs.createReadStream(filePath) 98 | .pipe(res); 99 | } 100 | 101 | export function sendError(httpStatus: number, res: http.ServerResponse, content: { [key: string]: any } = {}) { 102 | res.writeHead(httpStatus, { 103 | 'Cache-Control': 'no-cache, no-store, must-revalidate, max-age=0', 104 | 'Expires': '0', 105 | 'Content-Type': 'text/plain' 106 | }); 107 | res.write(JSON.stringify(content, null, 2)); 108 | res.end(); 109 | } 110 | -------------------------------------------------------------------------------- /src/promisify.ts: -------------------------------------------------------------------------------- 1 | export interface Promisify { 2 | (func: (callback: (err: any, result: T) => void) => void): () => Promise; 3 | (func: (arg1: A1, callback: (err: any, result: T) => void) => void): (arg1: A1) => Promise; 4 | (func: (arg1: A1, arg2: A2, callback: (err: any, result: T) => void) => void): (arg1: A1, arg2: A2) => Promise; 5 | (func: (arg1: A1, arg2: A2, arg3: A3, callback: (err: any, result: T) => void) => void): (arg1: A1, arg2: A2, arg3: A3) => Promise; 6 | (func: (arg1: A1, arg2: A2, arg3: A3, arg4: A4, callback: (err: any, result: T) => void) => void): (arg1: A1, arg2: A2, arg3: A3, arg4: A4) => Promise; 7 | (func: (arg1: A1, arg2: A2, arg3: A3, arg4: A4, arg5: A5, callback: (err: any, result: T) => void) => void): (arg1: A1, arg2: A2, arg3: A3, arg4: A4, arg5: A5) => Promise; 8 | } 9 | /** 10 | * @example: const rReadFile = promisify(fs.readFile); 11 | * 12 | */ 13 | export const promisify: Promisify = function(func: any) { 14 | return (...args: any[]) => { 15 | return new Promise((resolve, reject) => { 16 | func(...args, (err: any, response: any) => { 17 | if (err) { 18 | return reject(err); 19 | } 20 | resolve(response); 21 | }); 22 | }); 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as url from 'url'; 3 | import * as fs from 'fs'; 4 | import { promisify } from './promisify'; 5 | import getDevelopmentCertificate from 'devcert-san'; 6 | 7 | export const fsStatPr = promisify(fs.stat); 8 | export const fsReadFilePr = promisify(fs.readFile); 9 | export const fsReadDirPr = promisify(fs.readdir); 10 | 11 | export async function findClosestOpenPort(host: string, port: number): Promise { 12 | async function t(portToCheck: number): Promise { 13 | const isTaken = await isPortTaken(host, portToCheck); 14 | if (!isTaken) { 15 | return portToCheck; 16 | } 17 | return t(portToCheck + 1); 18 | } 19 | 20 | return t(port); 21 | } 22 | 23 | export function isPortTaken(host: string, port: number): Promise { 24 | return new Promise((resolve, reject) => { 25 | var net = require('net'); 26 | 27 | var tester = net.createServer() 28 | .once('error', function(err: any) { 29 | if (err.code !== 'EADDRINUSE') { 30 | return resolve(true); 31 | } 32 | resolve(true); 33 | }) 34 | .once('listening', function() { 35 | tester.once('close', function() { 36 | resolve(false); 37 | }) 38 | .close(); 39 | }) 40 | .on('error', (err: any) => { 41 | reject(err); 42 | }) 43 | .listen(port, host); 44 | }); 45 | } 46 | 47 | export function parseOptions(optionInfo: { [key: string]: any }, argv: string[]): { [key: string]: any } { 48 | return Object.keys(optionInfo).reduce((options, key) => { 49 | let foundIndex = argv.indexOf(`--${key}`); 50 | if (foundIndex === -1) { 51 | options[key] = optionInfo[key].default; 52 | return options; 53 | } 54 | switch (optionInfo[key].type) { 55 | case Boolean: 56 | options[key] = true; 57 | break; 58 | case Number: 59 | options[key] = parseInt(argv[foundIndex + 1], 10); 60 | break; 61 | default: 62 | options[key] = argv[foundIndex + 1]; 63 | } 64 | return options; 65 | }, <{[key: string]: any}>{}); 66 | } 67 | 68 | 69 | export async function parseConfigFile(baseDir: string, filePath: string): Promise<{ [key: string]: any }> { 70 | let config: { [key: string]: any} = {}; 71 | 72 | try { 73 | const configFile = await import(path.resolve(baseDir, filePath)); 74 | config = configFile.devServer || {}; 75 | } catch (err) { 76 | if (err.code === 'ENOENT' || err.code === 'ENOTDIR') { 77 | throw new Error(`The specified configFile does not exist: ${filePath}`); 78 | } 79 | if (err.code === 'EACCES') { 80 | throw new Error(`You do not have permission to read the specified configFile: ${filePath}`); 81 | } 82 | } 83 | return config; 84 | } 85 | 86 | export function getRequestedPath(requestUrl: string) { 87 | const parsed = url.parse(requestUrl); 88 | 89 | decodeURIComponent(requestUrl); 90 | return decodePathname(parsed.pathname || ''); 91 | } 92 | 93 | export function getFileFromPath(wwwRoot: string, requestUrl: string) { 94 | const pathname = getRequestedPath(requestUrl); 95 | 96 | return path.normalize( 97 | path.join(wwwRoot, 98 | path.relative( 99 | '/', 100 | pathname 101 | ) 102 | ) 103 | ); 104 | } 105 | 106 | 107 | export async function getSSL() { 108 | return installSSL().then((cert: any) => { 109 | return { 110 | key: fs.readFileSync(cert.keyPath, 'utf-8'), 111 | cert: fs.readFileSync(cert.certPath, 'utf-8') 112 | } 113 | }); 114 | } 115 | 116 | function installSSL() { 117 | try { 118 | // Certificates are cached by name, so two calls for getDevelopmentCertificate('foo') will return the same key and certificate 119 | return getDevelopmentCertificate('stencil-dev-server-ssl', { 120 | installCertutil: true 121 | }) 122 | } catch (err) { 123 | throw new Error(`Failed to generate dev SSL certificate: ${err}\n`) 124 | } 125 | } 126 | 127 | 128 | function decodePathname(pathname: string) { 129 | const pieces = pathname.replace(/\\/g,"/").split('/'); 130 | 131 | return pieces.map((piece) => { 132 | piece = decodeURIComponent(piece); 133 | 134 | if (process.platform === 'win32' && /\\/.test(piece)) { 135 | throw new Error('Invalid forward slash character'); 136 | } 137 | 138 | return piece; 139 | }).join('/'); 140 | } 141 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowUnreachableCode": false, 4 | "noUnusedLocals": true, 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "noImplicitAny": true, 8 | "strictNullChecks": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "target": "es2015", 11 | "lib":[ 12 | "dom", 13 | "es2016" 14 | ], 15 | "outDir": "dist", 16 | "sourceMap": false 17 | }, 18 | "include": [ 19 | "src/**/*.ts", 20 | "types" 21 | ], 22 | "exclude": [ 23 | "node_modules", 24 | "src/__tests__/**/*" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /types/devcert-san.d.ts: -------------------------------------------------------------------------------- 1 | declare module "devcert-san"; 2 | -------------------------------------------------------------------------------- /types/ecstatic.d.ts: -------------------------------------------------------------------------------- 1 | 2 | declare module "ecstatic" { 3 | import * as http from 'http'; 4 | interface ServeOptions { 5 | root: string 6 | port?: number 7 | baseDir?: string 8 | cache?: number 9 | showDir?: boolean 10 | showDotFiles?: boolean 11 | autoIndex?: boolean 12 | humanReadable?: boolean 13 | headers?: { [header_name: string]: any } 14 | si?: boolean 15 | defaultExt?: string 16 | gzip?: boolean 17 | serverHeader?: boolean 18 | contentType?: string 19 | mimeTypes?: { [file_extension: string]: string } 20 | handleOptionsMethod?: boolean 21 | } 22 | namespace ecstatic {} 23 | function ecstatic(options: ServeOptions): ((request: http.IncomingMessage, response: http.ServerResponse) => void) 24 | 25 | export = ecstatic; 26 | } 27 | -------------------------------------------------------------------------------- /types/tiny-lr.d.ts: -------------------------------------------------------------------------------- 1 | declare module "tiny-lr" { 2 | namespace tinyLr { 3 | export interface server { 4 | on: Function; 5 | listen: Function; 6 | close: Function; 7 | changed: Function; 8 | } 9 | } 10 | function tinyLr(options?:{ [ name: string]: any }): tinyLr.server; 11 | 12 | export = tinyLr; 13 | } 14 | --------------------------------------------------------------------------------