dev server: simple support for CORS requests (#4171)

This commit is contained in:
Evan Wallace
2025-05-05 20:17:48 -04:00
committed by GitHub
parent 8bf3368594
commit e66cd0bf6d
10 changed files with 221 additions and 3 deletions
+40
View File
@@ -2,6 +2,46 @@
## Unreleased
* Add simple support for CORS to esbuild's development server ([#4125](https://github.com/evanw/esbuild/issues/4125))
Starting with version 0.25.0, esbuild's development server is no longer configured to serve cross-origin requests. This was a deliberate change to prevent any website you visit from accessing your running esbuild development server. However, this change prevented (by design) certain use cases such as "debugging in production" by having your production website load code from `localhost` where the esbuild development server is running.
To enable this use case, esbuild is adding a feature to allow [Cross-Origin Resource Sharing](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS) (a.k.a. CORS) for [simple requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS#simple_requests). Specifically, passing your origin to the new `cors` option will now set the `Access-Control-Allow-Origin` response header when the request has a matching `Origin` header. Note that this currently only works for requests that don't send a preflight `OPTIONS` request, as esbuild's development server doesn't currently support `OPTIONS` requests.
Some examples:
* **CLI:**
```
esbuild --servedir=. --cors-origin=https://example.com
```
* **JS:**
```js
const ctx = await esbuild.context({})
await ctx.serve({
servedir: '.',
cors: {
origin: 'https://example.com',
},
})
```
* **Go:**
```go
ctx, _ := api.Context(api.BuildOptions{})
ctx.Serve(api.ServeOptions{
Servedir: ".",
CORS: api.CORSOptions{
Origin: []string{"https://example.com"},
},
})
```
The special origin `*` can be used to allow any origin to access esbuild's development server. Note that this means any website you visit will be able to read everything served by esbuild.
* Pass through invalid URLs in source maps unmodified ([#4169](https://github.com/evanw/esbuild/issues/4169))
This fixes a regression in version 0.25.0 where `sources` in source maps that form invalid URLs were not being passed through to the output. Version 0.25.0 changed the interpretation of `sources` from file paths to URLs, which means that URL parsing can now fail. Previously URLs that couldn't be parsed were replaced with the empty string. With this release, invalid URLs in `sources` should now be passed through unmodified.
+1
View File
@@ -68,6 +68,7 @@ var helpText = func(colors logger.Colors) string {
--chunk-names=... Path template to use for code splitting chunks
(default "[name]-[hash]")
--color=... Force use of color terminal escapes (true | false)
--cors-origin=... Allow cross-origin requests from this origin
--drop:... Remove certain constructs (console | debugger)
--drop-labels=... Remove labeled statements with these label names
--entry-names=... Path template to use for entry point output paths
+5
View File
@@ -392,6 +392,11 @@ func (service *serviceType) handleIncomingPacket(bytes []byte) {
if value, ok := request["fallback"]; ok {
options.Fallback = value.(string)
}
if value, ok := request["corsOrigin"].([]interface{}); ok {
for _, it := range value {
options.CORS.Origin = append(options.CORS.Origin, it.(string))
}
}
if request["onRequest"].(bool) {
options.OnRequest = func(args api.ServeOnRequestArgs) {
// This could potentially be called after we return from
+12 -3
View File
@@ -62,8 +62,8 @@ let mustBeStringOrBoolean = (value: string | boolean | undefined): string | null
let mustBeStringOrObject = (value: string | Object | undefined): string | null =>
typeof value === 'string' || typeof value === 'object' && value !== null && !Array.isArray(value) ? null : 'a string or an object'
let mustBeStringOrArray = (value: string | string[] | undefined): string | null =>
typeof value === 'string' || Array.isArray(value) ? null : 'a string or an array'
let mustBeStringOrArrayOfStrings = (value: string | string[] | undefined): string | null =>
typeof value === 'string' || (Array.isArray(value) && value.every(x => typeof x === 'string')) ? null : 'a string or an array of strings'
let mustBeStringOrUint8Array = (value: string | Uint8Array | undefined): string | null =>
typeof value === 'string' || value instanceof Uint8Array ? null : 'a string or a Uint8Array'
@@ -145,7 +145,7 @@ function pushCommonFlags(flags: string[], options: CommonOptions, keys: OptionKe
let legalComments = getFlag(options, keys, 'legalComments', mustBeString)
let sourceRoot = getFlag(options, keys, 'sourceRoot', mustBeString)
let sourcesContent = getFlag(options, keys, 'sourcesContent', mustBeBoolean)
let target = getFlag(options, keys, 'target', mustBeStringOrArray)
let target = getFlag(options, keys, 'target', mustBeStringOrArrayOfStrings)
let format = getFlag(options, keys, 'format', mustBeString)
let globalName = getFlag(options, keys, 'globalName', mustBeString)
let mangleProps = getFlag(options, keys, 'mangleProps', mustBeRegExp)
@@ -1080,6 +1080,7 @@ function buildOrContextImpl(
const keyfile = getFlag(options, keys, 'keyfile', mustBeString)
const certfile = getFlag(options, keys, 'certfile', mustBeString)
const fallback = getFlag(options, keys, 'fallback', mustBeString)
const cors = getFlag(options, keys, 'cors', mustBeObject)
const onRequest = getFlag(options, keys, 'onRequest', mustBeFunction)
checkForInvalidFlags(options, keys, `in serve() call`)
@@ -1095,6 +1096,14 @@ function buildOrContextImpl(
if (certfile !== void 0) request.certfile = certfile
if (fallback !== void 0) request.fallback = fallback
if (cors) {
const corsKeys: OptionKeys = {}
const origin = getFlag(cors, corsKeys, 'origin', mustBeStringOrArrayOfStrings)
checkForInvalidFlags(cors, corsKeys, `on "cors" object`)
if (Array.isArray(origin)) request.corsOrigin = origin
else if (origin !== void 0) request.corsOrigin = [origin]
}
sendRequest<protocol.ServeRequest, protocol.ServeResponse>(refs, request, (error, response) => {
if (error) return reject(new Error(error))
if (onRequest) {
+1
View File
@@ -31,6 +31,7 @@ export interface ServeRequest {
keyfile?: string
certfile?: string
fallback?: string
corsOrigin?: string[]
}
export interface ServeResponse {
+6
View File
@@ -241,9 +241,15 @@ export interface ServeOptions {
keyfile?: string
certfile?: string
fallback?: string
cors?: CORSOptions
onRequest?: (args: ServeOnRequestArgs) => void
}
/** Documentation: https://esbuild.github.io/api/#cors */
export interface CORSOptions {
origin?: string | string[]
}
export interface ServeOnRequestArgs {
remoteAddress: string
method: string
+6
View File
@@ -478,9 +478,15 @@ type ServeOptions struct {
Keyfile string
Certfile string
Fallback string
CORS CORSOptions
OnRequest func(ServeOnRequestArgs)
}
// Documentation: https://esbuild.github.io/api/#cors
type CORSOptions struct {
Origin []string
}
type ServeOnRequestArgs struct {
RemoteAddress string
Method string
+28
View File
@@ -49,6 +49,7 @@ type apiHandler struct {
certfileToLower string
fallback string
hosts []string
corsOrigin []string
serveWaitGroup sync.WaitGroup
activeStreams []chan serverSentEvent
currentHashes map[string]string
@@ -104,6 +105,25 @@ func errorsToString(errors []Message) string {
func (h *apiHandler) ServeHTTP(res http.ResponseWriter, req *http.Request) {
start := time.Now()
// Add CORS headers to all relevant requests
if origin := req.Header.Get("Origin"); origin != "" {
for _, allowed := range h.corsOrigin {
if allowed == "*" {
res.Header().Set("Access-Control-Allow-Origin", "*")
break
} else if star := strings.IndexByte(allowed, '*'); star >= 0 {
prefix, suffix := allowed[:star], allowed[star+1:]
if len(origin) >= len(prefix)+len(suffix) && strings.HasPrefix(origin, prefix) && strings.HasSuffix(origin, suffix) {
res.Header().Set("Access-Control-Allow-Origin", origin)
break
}
} else if origin == allowed {
res.Header().Set("Access-Control-Allow-Origin", origin)
break
}
}
}
// HEAD requests omit the body
maybeWriteResponseBody := func(bytes []byte) { res.Write(bytes) }
isHEAD := req.Method == "HEAD"
@@ -736,6 +756,13 @@ func (ctx *internalContext) Serve(serveOptions ServeOptions) (ServeResult, error
}
}
// Validate the CORS origins
for _, origin := range serveOptions.CORS.Origin {
if star := strings.IndexByte(origin, '*'); star >= 0 && strings.ContainsRune(origin[star+1:], '*') {
return ServeResult{}, fmt.Errorf("Invalid origin: %s", origin)
}
}
// Stuff related to the output directory only matters if there are entry points
outdirPathPrefix := ""
if len(ctx.args.entryPoints) > 0 {
@@ -868,6 +895,7 @@ func (ctx *internalContext) Serve(serveOptions ServeOptions) (ServeResult, error
certfileToLower: strings.ToLower(serveOptions.Certfile),
fallback: serveOptions.Fallback,
hosts: append([]string{}, result.Hosts...),
corsOrigin: append([]string{}, serveOptions.CORS.Origin...),
rebuild: func() BuildResult {
if atomic.LoadInt32(&shouldStop) != 0 {
// Don't start more rebuilds if we were told to stop
+7
View File
@@ -845,6 +845,7 @@ func parseOptionsImpl(
"chunk-names": true,
"color": true,
"conditions": true,
"cors-origin": true,
"drop-labels": true,
"entry-names": true,
"footer": true,
@@ -1375,6 +1376,7 @@ func parseServeOptionsImpl(osArgs []string) (api.ServeOptions, []string, error)
keyfile := ""
certfile := ""
fallback := ""
var corsOrigin []string
// Filter out server-specific flags
filteredArgs := make([]string, 0, len(osArgs))
@@ -1391,6 +1393,8 @@ func parseServeOptionsImpl(osArgs []string) (api.ServeOptions, []string, error)
certfile = arg[len("--certfile="):]
} else if strings.HasPrefix(arg, "--serve-fallback=") {
fallback = arg[len("--serve-fallback="):]
} else if strings.HasPrefix(arg, "--cors-origin=") {
corsOrigin = strings.Split(arg[len("--cors-origin="):], ",")
} else {
filteredArgs = append(filteredArgs, arg)
}
@@ -1429,6 +1433,9 @@ func parseServeOptionsImpl(osArgs []string) (api.ServeOptions, []string, error)
Keyfile: keyfile,
Certfile: certfile,
Fallback: fallback,
CORS: api.CORSOptions{
Origin: corsOrigin,
},
}, filteredArgs, nil
}
+115
View File
@@ -5305,6 +5305,121 @@ let serveTests = {
await context.dispose();
}
},
async serveCORSNoOrigins({ esbuild, testDir }) {
const input = path.join(testDir, 'in.js')
await writeFileAsync(input, `console.log(123)`)
const context = await esbuild.context({
entryPoints: [input],
format: 'esm',
outdir: testDir,
write: false,
});
try {
const result = await context.serve({
port: 0,
cors: {},
})
assert(result.hosts.length > 0);
assert.strictEqual(typeof result.port, 'number');
// There should be no CORS header
const origin = 'https://example.com'
const buffer = await fetch(result.hosts[0], result.port, '/in.js', { headers: { Origin: origin } })
assert.strictEqual(buffer.toString(), `console.log(123);\n`);
assert.strictEqual(fs.readFileSync(input, 'utf8'), `console.log(123)`)
assert.strictEqual(buffer.headers['access-control-allow-origin'], undefined)
} finally {
await context.dispose();
}
},
async serveCORSAllOrigins({ esbuild, testDir }) {
const input = path.join(testDir, 'in.js')
await writeFileAsync(input, `console.log(123)`)
const context = await esbuild.context({
entryPoints: [input],
format: 'esm',
outdir: testDir,
write: false,
});
try {
const result = await context.serve({
port: 0,
cors: { origin: '*' },
})
assert(result.hosts.length > 0);
assert.strictEqual(typeof result.port, 'number');
// There should be a CORS header allowing all origins
const origin = 'https://example.com'
const buffer = await fetch(result.hosts[0], result.port, '/in.js', { headers: { Origin: origin } })
assert.strictEqual(buffer.toString(), `console.log(123);\n`);
assert.strictEqual(fs.readFileSync(input, 'utf8'), `console.log(123)`)
assert.strictEqual(buffer.headers['access-control-allow-origin'], '*')
} finally {
await context.dispose();
}
},
async serveCORSSpecificOrigins({ esbuild, testDir }) {
const input = path.join(testDir, 'in.js')
await writeFileAsync(input, `console.log(123)`)
const context = await esbuild.context({
entryPoints: [input],
format: 'esm',
outdir: testDir,
write: false,
});
try {
const result = await context.serve({
port: 0,
cors: {
origin: [
'http://example.com',
'http://foo.example.com',
'https://*.example.com',
],
},
})
assert(result.hosts.length > 0);
assert.strictEqual(typeof result.port, 'number');
const allowedOrigins = [
'http://example.com',
'http://foo.example.com',
'https://bar.example.com',
]
const forbiddenOrigins = [
'http://bar.example.com',
'https://example.com',
'http://evil.com',
'https://evil.com',
]
// GET /in.js from each allowed origin
for (const origin of allowedOrigins) {
const buffer = await fetch(result.hosts[0], result.port, '/in.js', { headers: { Origin: origin } })
assert.strictEqual(buffer.toString(), `console.log(123);\n`);
assert.strictEqual(fs.readFileSync(input, 'utf8'), `console.log(123)`)
assert.strictEqual(buffer.headers['access-control-allow-origin'], origin)
}
// GET /in.js from each forbidden origin
for (const origin of forbiddenOrigins) {
const buffer = await fetch(result.hosts[0], result.port, '/in.js', { headers: { Origin: origin } })
assert.strictEqual(buffer.toString(), `console.log(123);\n`);
assert.strictEqual(fs.readFileSync(input, 'utf8'), `console.log(123)`)
assert.strictEqual(buffer.headers['access-control-allow-origin'], undefined)
}
} finally {
await context.dispose();
}
},
}
async function futureSyntax(esbuild, js, targetBelow, targetAbove) {