Skip to content

Commit

Permalink
fix #2827: live reload broken without servedir
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw committed Jan 16, 2023
1 parent d476c9c commit f12d93c
Show file tree
Hide file tree
Showing 3 changed files with 188 additions and 9 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

* Fix server-sent events without `servedir` ([#2827](https://github.com/evanw/esbuild/issues/2827))

The server-sent events for live reload were incorrectly using `servedir` to calculate the path to modified output files. This means events couldn't be sent when `servedir` wasn't specified. This release uses the internal output directory (which is always present) instead of `servedir` (which might be omitted), so live reload should now work when `servedir` is not specified.

## 0.17.0

**This release deliberately contains backwards-incompatible changes.** To avoid automatically picking up releases like this, you should either be pinning the exact version of `esbuild` in your `package.json` file (recommended) or be using a version range syntax that only accepts patch upgrades such as `^0.16.0` or `~0.16.0`. See npm's documentation about [semver](https://docs.npmjs.com/cli/v6/using-npm/semver/) for more information.
Expand Down
29 changes: 22 additions & 7 deletions pkg/api/serve_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,23 @@ func errorsToString(errors []Message) string {
return sb.String()
}

func stripDirPrefix(path string, prefix string, allowedSlashes string) (string, bool) {
if strings.HasPrefix(path, prefix) {
pathLen := len(path)
prefixLen := len(prefix)
if prefixLen == 0 {
return path, true
}
if pathLen > prefixLen && strings.IndexByte(allowedSlashes, path[prefixLen]) >= 0 {
return path[prefixLen+1:], true
}
if pathLen == prefixLen {
return "", true
}
}
return "", false
}

func (h *apiHandler) ServeHTTP(res http.ResponseWriter, req *http.Request) {
start := time.Now()

Expand Down Expand Up @@ -129,11 +146,7 @@ func (h *apiHandler) ServeHTTP(res http.ResponseWriter, req *http.Request) {
fileEntries := make(map[string]bool)

// Check for a match with the results if we're within the output directory
if strings.HasPrefix(queryPath, h.outdirPathPrefix) {
outdirQueryPath := queryPath[len(h.outdirPathPrefix):]
if strings.HasPrefix(outdirQueryPath, "/") {
outdirQueryPath = outdirQueryPath[1:]
}
if outdirQueryPath, ok := stripDirPrefix(queryPath, h.outdirPathPrefix, "/"); ok {
resultKind, inMemoryBytes, isImplicitIndexHTML := h.matchQueryPathToResult(outdirQueryPath, &result, dirEntries, fileEntries)
kind = resultKind
fileContents = &fs.InMemoryOpenedFile{Contents: inMemoryBytes}
Expand Down Expand Up @@ -381,13 +394,15 @@ func (h *apiHandler) broadcastBuildResult(result BuildResult, newSummary buildSu
var updated []string

urlForPath := func(absPath string) (string, bool) {
if relPath, ok := h.fs.Rel(h.servedir, absPath); ok {
if relPath, ok := stripDirPrefix(absPath, h.absOutputDir, "\\/"); ok {
relPath = strings.ReplaceAll(relPath, "\\", "/")
relPath = path.Join(h.outdirPathPrefix, relPath)
publicPath := h.publicPath
slash := "/"
if publicPath != "" && strings.HasSuffix(h.publicPath, "/") {
slash = ""
}
return fmt.Sprintf("%s%s%s", publicPath, slash, strings.ReplaceAll(relPath, "\\", "/")), true
return fmt.Sprintf("%s%s%s", publicPath, slash, relPath), true
}
return "", false
}
Expand Down
162 changes: 160 additions & 2 deletions scripts/js-api-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -4204,6 +4204,21 @@ let serveTests = {
assert.strictEqual(req.status, 200);
assert.strictEqual(typeof req.remoteAddress, 'string');
assert.strictEqual(typeof req.timeInMS, 'number');

// Make sure the output directory prefix requires a slash separator
promise = nextRequestPromise;
try {
await fetch(result.host, result.port, '/outin.js')
throw new Error('Expected an error to be thrown')
} catch (err) {
if (err.statusCode !== 404) throw err
}
req = await promise;
assert.strictEqual(req.method, 'GET');
assert.strictEqual(req.path, '/outin.js');
assert.strictEqual(req.status, 404);
assert.strictEqual(typeof req.remoteAddress, 'string');
assert.strictEqual(typeof req.timeInMS, 'number');
} finally {
await context.dispose();
}
Expand Down Expand Up @@ -4348,7 +4363,78 @@ let serveTests = {
}
},

async serveWatchLiveReload({ esbuild, testDir }) {
async serveWithoutServedirWatchLiveReload({ esbuild, testDir }) {
const js = path.join(testDir, 'app.js')
const css = path.join(testDir, 'app.css')
const outdir = path.join(testDir, 'out')
await writeFileAsync(css, ``)

let endPromise
const context = await esbuild.context({
entryPoints: [js],
outdir,
bundle: true,
logLevel: 'silent',
});

try {
const server = await context.serve({
host: '127.0.0.1',
})
const stream = await makeEventStream(server.host, server.port, '/esbuild')
await context.rebuild().then(
() => Promise.reject(new Error('Expected an error to be thrown')),
() => { /* Ignore the build error due to the missing JS file */ },
)

// Event 1: a new JavaScript file
var eventPromise = stream.waitFor('change')
await writeFileAsync(js, ``)
await context.rebuild()
var data = JSON.parse((await eventPromise).data)
assert.deepStrictEqual(data, { added: ['/app.js'], removed: [], updated: [] })

// Event 2: edit the JavaScript file
var eventPromise = stream.waitFor('change')
await writeFileAsync(js, `foo()`)
await context.rebuild()
var data = JSON.parse((await eventPromise).data)
assert.deepStrictEqual(data, { added: [], removed: [], updated: ['/app.js'] })

// Event 3: a new CSS file
var eventPromise = stream.waitFor('change')
await writeFileAsync(js, `import "./app.css"; foo()`)
await context.rebuild()
var data = JSON.parse((await eventPromise).data)
assert.deepStrictEqual(data, { added: ['/app.css'], removed: [], updated: [] })

// Event 4: edit the CSS file
var eventPromise = stream.waitFor('change')
await writeFileAsync(css, `a { color: red }`)
await context.rebuild()
var data = JSON.parse((await eventPromise).data)
assert.deepStrictEqual(data, { added: [], removed: [], updated: ['/app.css'] })

// Event 5: remove the CSS file
var eventPromise = stream.waitFor('change')
await writeFileAsync(js, `bar()`)
await context.rebuild()
var data = JSON.parse((await eventPromise).data)
assert.deepStrictEqual(data, { added: [], removed: ['/app.css'], updated: ['/app.js'] })

// Wait for the stream to end once we call "dispose()" below
endPromise = stream.waitFor('close')
}

finally {
await context.dispose();
}

// This stream should end once "dispose()" is called above
await endPromise
},

async serveWithServedirWatchLiveReload({ esbuild, testDir }) {
const js = path.join(testDir, 'app.js')
const css = path.join(testDir, 'app.css')
const outdir = path.join(testDir, 'out')
Expand Down Expand Up @@ -4420,7 +4506,79 @@ let serveTests = {
await endPromise
},

async serveWatchLiveReloadPublicPath({ esbuild, testDir }) {
async serveWithoutServedirWatchLiveReloadPublicPath({ esbuild, testDir }) {
const js = path.join(testDir, 'app.js')
const css = path.join(testDir, 'app.css')
const outdir = path.join(testDir, 'out')
await writeFileAsync(css, ``)

let endPromise
const context = await esbuild.context({
entryPoints: [js],
outdir,
bundle: true,
logLevel: 'silent',
publicPath: 'http://example.com/about',
});

try {
const server = await context.serve({
host: '127.0.0.1',
})
const stream = await makeEventStream(server.host, server.port, '/esbuild')
await context.rebuild().then(
() => Promise.reject(new Error('Expected an error to be thrown')),
() => { /* Ignore the build error due to the missing JS file */ },
)

// Event 1: a new JavaScript file
var eventPromise = stream.waitFor('change')
await writeFileAsync(js, ``)
await context.rebuild()
var data = JSON.parse((await eventPromise).data)
assert.deepStrictEqual(data, { added: ['http://example.com/about/app.js'], removed: [], updated: [] })

// Event 2: edit the JavaScript file
var eventPromise = stream.waitFor('change')
await writeFileAsync(js, `foo()`)
await context.rebuild()
var data = JSON.parse((await eventPromise).data)
assert.deepStrictEqual(data, { added: [], removed: [], updated: ['http://example.com/about/app.js'] })

// Event 3: a new CSS file
var eventPromise = stream.waitFor('change')
await writeFileAsync(js, `import "./app.css"; foo()`)
await context.rebuild()
var data = JSON.parse((await eventPromise).data)
assert.deepStrictEqual(data, { added: ['http://example.com/about/app.css'], removed: [], updated: [] })

// Event 4: edit the CSS file
var eventPromise = stream.waitFor('change')
await writeFileAsync(css, `a { color: red }`)
await context.rebuild()
var data = JSON.parse((await eventPromise).data)
assert.deepStrictEqual(data, { added: [], removed: [], updated: ['http://example.com/about/app.css'] })

// Event 5: remove the CSS file
var eventPromise = stream.waitFor('change')
await writeFileAsync(js, `bar()`)
await context.rebuild()
var data = JSON.parse((await eventPromise).data)
assert.deepStrictEqual(data, { added: [], removed: ['http://example.com/about/app.css'], updated: ['http://example.com/about/app.js'] })

// Wait for the stream to end once we call "dispose()" below
endPromise = stream.waitFor('close')
}

finally {
await context.dispose();
}

// This stream should end once "dispose()" is called above
await endPromise
},

async serveWithServedirWatchLiveReloadPublicPath({ esbuild, testDir }) {
const js = path.join(testDir, 'app.js')
const css = path.join(testDir, 'app.css')
const outdir = path.join(testDir, 'out')
Expand Down

0 comments on commit f12d93c

Please sign in to comment.