Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add major features from webpack-dev-server #55

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,14 @@
"dist"
],
"dependencies": {
"compression": "^1.7.4",
"connect-history-api-fallback": "^1.6.0",
"express": "^4.17.1",
"http-proxy-middleware": "^1.0.0",
"killable": "^1.0.1",
"mime": ">=2.0.3",
"opener": "1"
"opener": "1",
"serve-index": "^1.9.1"
Comment on lines +36 to +43
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about moving all of these to devDependencies and bundling them in the plugin with rollup?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems ok.

},
"devDependencies": {
"rollup": "1",
Expand Down
299 changes: 236 additions & 63 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { readFile } from 'fs'
import Express from 'express'
import killable from 'killable'
import compress from 'compression'
import serveIndex from 'serve-index'
import historyApiFallback from 'connect-history-api-fallback'
import { createProxyMiddleware } from 'http-proxy-middleware'
import { createServer as createHttpsServer } from 'https'
import { createServer } from 'http'
import { resolve } from 'path'
Expand All @@ -7,6 +12,7 @@ import mime from 'mime'
import opener from 'opener'

let server
let app

/**
* Serve your rolled up bundle like webpack-dev-server
Expand All @@ -17,61 +23,259 @@ function serve (options = { contentBase: '' }) {
options = { contentBase: options }
}
options.contentBase = Array.isArray(options.contentBase) ? options.contentBase : [options.contentBase || '']
options.contentBasePublicPath = options.contentBasePublicPath || '/'
options.port = options.port || 10001
options.headers = options.headers || {}
options.https = options.https || false
options.openPage = options.openPage || ''
options.compress = !!options.compress
options.serveIndex = options.serveIndex || (options.serveIndex === undefined)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These new options should be documented in README.md

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, obviously. And some tests added.

mime.default_type = 'text/plain'

const requestListener = (request, response) => {
// Remove querystring
const urlPath = decodeURI(request.url.split('?')[0])
function setupProxy () {
/**
* Assume a proxy configuration specified as:
* proxy: {
* 'context': { options }
* }
* OR
* proxy: {
* 'context': 'target'
* }
*/
if (!Array.isArray(options.proxy)) {
if (Object.prototype.hasOwnProperty.call(options.proxy, 'target')) {
options.proxy = [options.proxy]
} else {
options.proxy = Object.keys(options.proxy).map((context) => {
let proxyOptions
// For backwards compatibility reasons.
const correctedContext = context
.replace(/^\*$/, '**')
.replace(/\/\*$/, '')

Object.keys(options.headers).forEach((key) => {
response.setHeader(key, options.headers[key])
})
if (typeof options.proxy[context] === 'string') {
proxyOptions = {
context: correctedContext,
target: options.proxy[context]
}
} else {
proxyOptions = Object.assign({}, options.proxy[context])
proxyOptions.context = correctedContext
}

proxyOptions.logLevel = proxyOptions.logLevel || 'warn'

readFileFromContentBase(options.contentBase, urlPath, function (error, content, filePath) {
if (!error) {
return found(response, filePath, content)
return proxyOptions
})
}
if (error.code !== 'ENOENT') {
response.writeHead(500)
response.end('500 Internal Server Error' +
'\n\n' + filePath +
'\n\n' + Object.values(error).join('\n') +
'\n\n(rollup-plugin-serve)', 'utf-8')
return
}

const getProxyMiddleware = (proxyConfig) => {
const context = proxyConfig.context || proxyConfig.path

// It is possible to use the `bypass` method without a `target`.
// However, the proxy middleware has no use in this case, and will fail to instantiate.
if (proxyConfig.target) {
return createProxyMiddleware(context, proxyConfig)
}
if (options.historyApiFallback) {
const fallbackPath = typeof options.historyApiFallback === 'string' ? options.historyApiFallback : '/index.html'
readFileFromContentBase(options.contentBase, fallbackPath, function (error, content, filePath) {
if (error) {
notFound(response, filePath)
} else {
found(response, filePath, content)
}
/**
* Assume a proxy configuration specified as:
* proxy: [
* {
* context: ...,
* ...options...
* },
* // or:
* function() {
* return {
* context: ...,
* ...options...
* };
* }
* ]
*/
options.proxy.forEach((proxyConfigOrCallback) => {
let proxyMiddleware

let proxyConfig =
typeof proxyConfigOrCallback === 'function'
? proxyConfigOrCallback()
: proxyConfigOrCallback

proxyMiddleware = getProxyMiddleware(proxyConfig)

function proxyHandle (req, res, next) {
if (typeof proxyConfigOrCallback === 'function') {
const newProxyConfig = proxyConfigOrCallback()

if (newProxyConfig !== proxyConfig) {
proxyConfig = newProxyConfig
proxyMiddleware = getProxyMiddleware(proxyConfig)
}
})
} else {
notFound(response, filePath)
}

// - Check if we have a bypass function defined
// - In case the bypass function is defined we'll retrieve the
// bypassUrl from it otherwise bypassUrl would be null
const isByPassFuncDefined = typeof proxyConfig.bypass === 'function'
const bypassUrl = isByPassFuncDefined
? proxyConfig.bypass(req, res, proxyConfig)
: null

if (typeof bypassUrl === 'boolean') {
// skip the proxy
req.url = null
next()
} else if (typeof bypassUrl === 'string') {
// byPass to that url
req.url = bypassUrl
next()
} else if (proxyMiddleware) {
return proxyMiddleware(req, res, next)
} else {
next()
}
}

app.use(proxyHandle)
// Also forward error requests to the proxy so it can handle them.
// eslint-disable-next-line handle-callback-err
app.use((error, req, res, next) => proxyHandle(req, res, next))
})
}

// release previous server instance if rollup is reloading configuration in watch mode
if (server) {
server.close()
server.kill()
} else {
closeServerOnTermination()
}

app = new Express()

// Implement webpack-dev-server features
const features = {
compress: () => {
if (options.compress) {
app.use(compress())
}
},
proxy: () => {
if (options.proxy) {
setupProxy()
}
},
historyApiFallback: () => {
if (options.historyApiFallback) {
const fallback =
typeof options.historyApiFallback === 'object'
? options.historyApiFallback
: typeof options.historyApiFallback === 'string'
? { index: options.historyApiFallback, disableDotRule: true } : null

app.use(historyApiFallback(fallback))
}
},
contentBaseFiles: () => {
if (Array.isArray(options.contentBase)) {
options.contentBase.forEach((item) => {
app.use(options.contentBasePublicPath, Express.static(item))
})
} else {
app.use(
options.contentBasePublicPath,
Express.static(options.contentBase, options.staticOptions)
)
}
},
contentBaseIndex: () => {
if (options.contentBase && options.serveIndex) {
const getHandler = item => function indexHandler (req, res, next) {
// serve-index doesn't fallthrough non-get/head request to next middleware
if (req.method !== 'GET' && req.method !== 'HEAD') {
return next()
}

serveIndex(item)(req, res, next)
Copy link

@prantlf prantlf Dec 12, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The interface of serveIndex is serveIndex(path, options). There're some important options there like icons: true, for which my colleague is mourning now, when migrating from webpack to rollup ;-) How about passing the options for serve-index down?

This is possible now, using the defaults of serve-index:

serve({
  open: false,
  port: 8080,
  contentBase: __dirname,
  serveIndex: true
})

How about letting us customise the options of serve-index like this:

serve({
  open: false,
  port: 8080,
  contentBase: __dirname,
  serveIndex: { icons: true }
})

It'd mean this code change:

- serveIndex(item)(req, res, next)
+ serveIndex(item, options.serveIndex)(req, res, next)

}
if (Array.isArray(options.contentBase)) {
options.contentBase.forEach((item) => {
app.use(options.contentBasePublicPath, getHandler(item))
})
} else {
app.use(options.contentBasePublicPath, getHandler(options.contentBase))
}
}
},
before: () => {
if (typeof options.before === 'function') {
options.before(app)
}
},
after: () => {
if (typeof options.after === 'function') {
options.after(app)
}
},
headers: () => {
app.all('*', function headersHandler (req, res, next) {
if (options.headers) {
for (const name in options.headers) {
res.setHeader(name, options.headers[name])
}
}
next()
})
}
}

const runnableFeatures = []
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How much of this code is copied from webpack-dev-server?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some of the complex features. Like proxy and feature order.
I don't see how to get feature/bug parity without it.
The license is MIT though, so probably needs a better embedding of the source with the correct attribution.


if (options.compress) {
runnableFeatures.push('compress')
}

runnableFeatures.push('before', 'headers')

if (options.proxy) {
runnableFeatures.push('proxy')
}

if (options.contentBase !== false) {
runnableFeatures.push('contentBaseFiles')
}

if (options.historyApiFallback) {
runnableFeatures.push('historyApiFallback')

if (options.contentBase !== false) {
runnableFeatures.push('contentBaseFiles')
}
}

if (options.contentBase && options.serveIndex) {
runnableFeatures.push('contentBaseIndex')
}

if (options.after) {
runnableFeatures.push('after')
}

(options.features || runnableFeatures).forEach((feature) => {
features[feature]()
})

// If HTTPS options are available, create an HTTPS server
if (options.https) {
server = createHttpsServer(options.https, requestListener).listen(options.port, options.host)
server = createHttpsServer(options.https, app).listen(options.port, options.host)
} else {
server = createServer(requestListener).listen(options.port, options.host)
server = createServer(app).listen(options.port, options.host)
}

killable(server)

let running = options.verbose === false

return {
Expand Down Expand Up @@ -99,47 +303,16 @@ function serve (options = { contentBase: '' }) {
}
}

function readFileFromContentBase (contentBase, urlPath, callback) {
let filePath = resolve(contentBase[0] || '.', '.' + urlPath)

// Load index.html in directories
if (urlPath.endsWith('/')) {
filePath = resolve(filePath, 'index.html')
}

readFile(filePath, (error, content) => {
if (error && contentBase.length > 1) {
// Try to read from next contentBase
readFileFromContentBase(contentBase.slice(1), urlPath, callback)
} else {
// We know enough
callback(error, content, filePath)
}
})
}

function notFound (response, filePath) {
response.writeHead(404)
response.end('404 Not Found' +
'\n\n' + filePath +
'\n\n(rollup-plugin-serve)', 'utf-8')
}

function found (response, filePath, content) {
response.writeHead(200, { 'Content-Type': mime.getType(filePath) })
response.end(content, 'utf-8')
}

function green (text) {
return '\u001b[1m\u001b[32m' + text + '\u001b[39m\u001b[22m'
}

function closeServerOnTermination() {
function closeServerOnTermination () {
const terminationSignals = ['SIGINT', 'SIGTERM', 'SIGQUIT', 'SIGHUP']
terminationSignals.forEach(signal => {
process.on(signal, () => {
if (server) {
server.close()
server.kill()
process.exit()
}
})
Expand Down