mirror of
https://github.com/hanzoai/esbuild.git
synced 2026-09-24 16:58:50 +00:00
dev server: simple support for CORS requests (#4171)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface ServeRequest {
|
||||
keyfile?: string
|
||||
certfile?: string
|
||||
fallback?: string
|
||||
corsOrigin?: string[]
|
||||
}
|
||||
|
||||
export interface ServeResponse {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user