Skip to content

Commit e66cd0b

Browse files
authored
dev server: simple support for CORS requests (#4171)
1 parent 8bf3368 commit e66cd0b

File tree

10 files changed

+221
-3
lines changed

10 files changed

+221
-3
lines changed

CHANGELOG.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,46 @@
22

33
## Unreleased
44

5+
* Add simple support for CORS to esbuild's development server ([#4125](https://github.com/evanw/esbuild/issues/4125))
6+
7+
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.
8+
9+
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.
10+
11+
Some examples:
12+
13+
* **CLI:**
14+
15+
```
16+
esbuild --servedir=. --cors-origin=https://example.com
17+
```
18+
19+
* **JS:**
20+
21+
```js
22+
const ctx = await esbuild.context({})
23+
await ctx.serve({
24+
servedir: '.',
25+
cors: {
26+
origin: 'https://example.com',
27+
},
28+
})
29+
```
30+
31+
* **Go:**
32+
33+
```go
34+
ctx, _ := api.Context(api.BuildOptions{})
35+
ctx.Serve(api.ServeOptions{
36+
Servedir: ".",
37+
CORS: api.CORSOptions{
38+
Origin: []string{"https://example.com"},
39+
},
40+
})
41+
```
42+
43+
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.
44+
545
* Pass through invalid URLs in source maps unmodified ([#4169](https://github.com/evanw/esbuild/issues/4169))
646
747
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.

cmd/esbuild/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ var helpText = func(colors logger.Colors) string {
6868
--chunk-names=... Path template to use for code splitting chunks
6969
(default "[name]-[hash]")
7070
--color=... Force use of color terminal escapes (true | false)
71+
--cors-origin=... Allow cross-origin requests from this origin
7172
--drop:... Remove certain constructs (console | debugger)
7273
--drop-labels=... Remove labeled statements with these label names
7374
--entry-names=... Path template to use for entry point output paths

cmd/esbuild/service.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,11 @@ func (service *serviceType) handleIncomingPacket(bytes []byte) {
392392
if value, ok := request["fallback"]; ok {
393393
options.Fallback = value.(string)
394394
}
395+
if value, ok := request["corsOrigin"].([]interface{}); ok {
396+
for _, it := range value {
397+
options.CORS.Origin = append(options.CORS.Origin, it.(string))
398+
}
399+
}
395400
if request["onRequest"].(bool) {
396401
options.OnRequest = func(args api.ServeOnRequestArgs) {
397402
// This could potentially be called after we return from

lib/shared/common.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,8 @@ let mustBeStringOrBoolean = (value: string | boolean | undefined): string | null
6262
let mustBeStringOrObject = (value: string | Object | undefined): string | null =>
6363
typeof value === 'string' || typeof value === 'object' && value !== null && !Array.isArray(value) ? null : 'a string or an object'
6464

65-
let mustBeStringOrArray = (value: string | string[] | undefined): string | null =>
66-
typeof value === 'string' || Array.isArray(value) ? null : 'a string or an array'
65+
let mustBeStringOrArrayOfStrings = (value: string | string[] | undefined): string | null =>
66+
typeof value === 'string' || (Array.isArray(value) && value.every(x => typeof x === 'string')) ? null : 'a string or an array of strings'
6767

6868
let mustBeStringOrUint8Array = (value: string | Uint8Array | undefined): string | null =>
6969
typeof value === 'string' || value instanceof Uint8Array ? null : 'a string or a Uint8Array'
@@ -145,7 +145,7 @@ function pushCommonFlags(flags: string[], options: CommonOptions, keys: OptionKe
145145
let legalComments = getFlag(options, keys, 'legalComments', mustBeString)
146146
let sourceRoot = getFlag(options, keys, 'sourceRoot', mustBeString)
147147
let sourcesContent = getFlag(options, keys, 'sourcesContent', mustBeBoolean)
148-
let target = getFlag(options, keys, 'target', mustBeStringOrArray)
148+
let target = getFlag(options, keys, 'target', mustBeStringOrArrayOfStrings)
149149
let format = getFlag(options, keys, 'format', mustBeString)
150150
let globalName = getFlag(options, keys, 'globalName', mustBeString)
151151
let mangleProps = getFlag(options, keys, 'mangleProps', mustBeRegExp)
@@ -1080,6 +1080,7 @@ function buildOrContextImpl(
10801080
const keyfile = getFlag(options, keys, 'keyfile', mustBeString)
10811081
const certfile = getFlag(options, keys, 'certfile', mustBeString)
10821082
const fallback = getFlag(options, keys, 'fallback', mustBeString)
1083+
const cors = getFlag(options, keys, 'cors', mustBeObject)
10831084
const onRequest = getFlag(options, keys, 'onRequest', mustBeFunction)
10841085
checkForInvalidFlags(options, keys, `in serve() call`)
10851086

@@ -1095,6 +1096,14 @@ function buildOrContextImpl(
10951096
if (certfile !== void 0) request.certfile = certfile
10961097
if (fallback !== void 0) request.fallback = fallback
10971098

1099+
if (cors) {
1100+
const corsKeys: OptionKeys = {}
1101+
const origin = getFlag(cors, corsKeys, 'origin', mustBeStringOrArrayOfStrings)
1102+
checkForInvalidFlags(cors, corsKeys, `on "cors" object`)
1103+
if (Array.isArray(origin)) request.corsOrigin = origin
1104+
else if (origin !== void 0) request.corsOrigin = [origin]
1105+
}
1106+
10981107
sendRequest<protocol.ServeRequest, protocol.ServeResponse>(refs, request, (error, response) => {
10991108
if (error) return reject(new Error(error))
11001109
if (onRequest) {

lib/shared/stdio_protocol.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export interface ServeRequest {
3131
keyfile?: string
3232
certfile?: string
3333
fallback?: string
34+
corsOrigin?: string[]
3435
}
3536

3637
export interface ServeResponse {

lib/shared/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,9 +241,15 @@ export interface ServeOptions {
241241
keyfile?: string
242242
certfile?: string
243243
fallback?: string
244+
cors?: CORSOptions
244245
onRequest?: (args: ServeOnRequestArgs) => void
245246
}
246247

248+
/** Documentation: https://esbuild.github.io/api/#cors */
249+
export interface CORSOptions {
250+
origin?: string | string[]
251+
}
252+
247253
export interface ServeOnRequestArgs {
248254
remoteAddress: string
249255
method: string

pkg/api/api.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,9 +478,15 @@ type ServeOptions struct {
478478
Keyfile string
479479
Certfile string
480480
Fallback string
481+
CORS CORSOptions
481482
OnRequest func(ServeOnRequestArgs)
482483
}
483484

485+
// Documentation: https://esbuild.github.io/api/#cors
486+
type CORSOptions struct {
487+
Origin []string
488+
}
489+
484490
type ServeOnRequestArgs struct {
485491
RemoteAddress string
486492
Method string

pkg/api/serve_other.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ type apiHandler struct {
4949
certfileToLower string
5050
fallback string
5151
hosts []string
52+
corsOrigin []string
5253
serveWaitGroup sync.WaitGroup
5354
activeStreams []chan serverSentEvent
5455
currentHashes map[string]string
@@ -104,6 +105,25 @@ func errorsToString(errors []Message) string {
104105
func (h *apiHandler) ServeHTTP(res http.ResponseWriter, req *http.Request) {
105106
start := time.Now()
106107

108+
// Add CORS headers to all relevant requests
109+
if origin := req.Header.Get("Origin"); origin != "" {
110+
for _, allowed := range h.corsOrigin {
111+
if allowed == "*" {
112+
res.Header().Set("Access-Control-Allow-Origin", "*")
113+
break
114+
} else if star := strings.IndexByte(allowed, '*'); star >= 0 {
115+
prefix, suffix := allowed[:star], allowed[star+1:]
116+
if len(origin) >= len(prefix)+len(suffix) && strings.HasPrefix(origin, prefix) && strings.HasSuffix(origin, suffix) {
117+
res.Header().Set("Access-Control-Allow-Origin", origin)
118+
break
119+
}
120+
} else if origin == allowed {
121+
res.Header().Set("Access-Control-Allow-Origin", origin)
122+
break
123+
}
124+
}
125+
}
126+
107127
// HEAD requests omit the body
108128
maybeWriteResponseBody := func(bytes []byte) { res.Write(bytes) }
109129
isHEAD := req.Method == "HEAD"
@@ -736,6 +756,13 @@ func (ctx *internalContext) Serve(serveOptions ServeOptions) (ServeResult, error
736756
}
737757
}
738758

759+
// Validate the CORS origins
760+
for _, origin := range serveOptions.CORS.Origin {
761+
if star := strings.IndexByte(origin, '*'); star >= 0 && strings.ContainsRune(origin[star+1:], '*') {
762+
return ServeResult{}, fmt.Errorf("Invalid origin: %s", origin)
763+
}
764+
}
765+
739766
// Stuff related to the output directory only matters if there are entry points
740767
outdirPathPrefix := ""
741768
if len(ctx.args.entryPoints) > 0 {
@@ -868,6 +895,7 @@ func (ctx *internalContext) Serve(serveOptions ServeOptions) (ServeResult, error
868895
certfileToLower: strings.ToLower(serveOptions.Certfile),
869896
fallback: serveOptions.Fallback,
870897
hosts: append([]string{}, result.Hosts...),
898+
corsOrigin: append([]string{}, serveOptions.CORS.Origin...),
871899
rebuild: func() BuildResult {
872900
if atomic.LoadInt32(&shouldStop) != 0 {
873901
// Don't start more rebuilds if we were told to stop

pkg/cli/cli_impl.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -845,6 +845,7 @@ func parseOptionsImpl(
845845
"chunk-names": true,
846846
"color": true,
847847
"conditions": true,
848+
"cors-origin": true,
848849
"drop-labels": true,
849850
"entry-names": true,
850851
"footer": true,
@@ -1375,6 +1376,7 @@ func parseServeOptionsImpl(osArgs []string) (api.ServeOptions, []string, error)
13751376
keyfile := ""
13761377
certfile := ""
13771378
fallback := ""
1379+
var corsOrigin []string
13781380

13791381
// Filter out server-specific flags
13801382
filteredArgs := make([]string, 0, len(osArgs))
@@ -1391,6 +1393,8 @@ func parseServeOptionsImpl(osArgs []string) (api.ServeOptions, []string, error)
13911393
certfile = arg[len("--certfile="):]
13921394
} else if strings.HasPrefix(arg, "--serve-fallback=") {
13931395
fallback = arg[len("--serve-fallback="):]
1396+
} else if strings.HasPrefix(arg, "--cors-origin=") {
1397+
corsOrigin = strings.Split(arg[len("--cors-origin="):], ",")
13941398
} else {
13951399
filteredArgs = append(filteredArgs, arg)
13961400
}
@@ -1429,6 +1433,9 @@ func parseServeOptionsImpl(osArgs []string) (api.ServeOptions, []string, error)
14291433
Keyfile: keyfile,
14301434
Certfile: certfile,
14311435
Fallback: fallback,
1436+
CORS: api.CORSOptions{
1437+
Origin: corsOrigin,
1438+
},
14321439
}, filteredArgs, nil
14331440
}
14341441

scripts/js-api-tests.js

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5305,6 +5305,121 @@ let serveTests = {
53055305
await context.dispose();
53065306
}
53075307
},
5308+
5309+
async serveCORSNoOrigins({ esbuild, testDir }) {
5310+
const input = path.join(testDir, 'in.js')
5311+
await writeFileAsync(input, `console.log(123)`)
5312+
5313+
const context = await esbuild.context({
5314+
entryPoints: [input],
5315+
format: 'esm',
5316+
outdir: testDir,
5317+
write: false,
5318+
});
5319+
try {
5320+
const result = await context.serve({
5321+
port: 0,
5322+
cors: {},
5323+
})
5324+
assert(result.hosts.length > 0);
5325+
assert.strictEqual(typeof result.port, 'number');
5326+
5327+
// There should be no CORS header
5328+
const origin = 'https://example.com'
5329+
const buffer = await fetch(result.hosts[0], result.port, '/in.js', { headers: { Origin: origin } })
5330+
assert.strictEqual(buffer.toString(), `console.log(123);\n`);
5331+
assert.strictEqual(fs.readFileSync(input, 'utf8'), `console.log(123)`)
5332+
assert.strictEqual(buffer.headers['access-control-allow-origin'], undefined)
5333+
} finally {
5334+
await context.dispose();
5335+
}
5336+
},
5337+
5338+
async serveCORSAllOrigins({ esbuild, testDir }) {
5339+
const input = path.join(testDir, 'in.js')
5340+
await writeFileAsync(input, `console.log(123)`)
5341+
5342+
const context = await esbuild.context({
5343+
entryPoints: [input],
5344+
format: 'esm',
5345+
outdir: testDir,
5346+
write: false,
5347+
});
5348+
try {
5349+
const result = await context.serve({
5350+
port: 0,
5351+
cors: { origin: '*' },
5352+
})
5353+
assert(result.hosts.length > 0);
5354+
assert.strictEqual(typeof result.port, 'number');
5355+
5356+
// There should be a CORS header allowing all origins
5357+
const origin = 'https://example.com'
5358+
const buffer = await fetch(result.hosts[0], result.port, '/in.js', { headers: { Origin: origin } })
5359+
assert.strictEqual(buffer.toString(), `console.log(123);\n`);
5360+
assert.strictEqual(fs.readFileSync(input, 'utf8'), `console.log(123)`)
5361+
assert.strictEqual(buffer.headers['access-control-allow-origin'], '*')
5362+
} finally {
5363+
await context.dispose();
5364+
}
5365+
},
5366+
5367+
async serveCORSSpecificOrigins({ esbuild, testDir }) {
5368+
const input = path.join(testDir, 'in.js')
5369+
await writeFileAsync(input, `console.log(123)`)
5370+
5371+
const context = await esbuild.context({
5372+
entryPoints: [input],
5373+
format: 'esm',
5374+
outdir: testDir,
5375+
write: false,
5376+
});
5377+
try {
5378+
const result = await context.serve({
5379+
port: 0,
5380+
cors: {
5381+
origin: [
5382+
'http://example.com',
5383+
'http://foo.example.com',
5384+
'https://*.example.com',
5385+
],
5386+
},
5387+
})
5388+
assert(result.hosts.length > 0);
5389+
assert.strictEqual(typeof result.port, 'number');
5390+
5391+
const allowedOrigins = [
5392+
'http://example.com',
5393+
'http://foo.example.com',
5394+
'https://bar.example.com',
5395+
]
5396+
5397+
const forbiddenOrigins = [
5398+
'http://bar.example.com',
5399+
'https://example.com',
5400+
'http://evil.com',
5401+
'https://evil.com',
5402+
]
5403+
5404+
// GET /in.js from each allowed origin
5405+
for (const origin of allowedOrigins) {
5406+
const buffer = await fetch(result.hosts[0], result.port, '/in.js', { headers: { Origin: origin } })
5407+
assert.strictEqual(buffer.toString(), `console.log(123);\n`);
5408+
assert.strictEqual(fs.readFileSync(input, 'utf8'), `console.log(123)`)
5409+
assert.strictEqual(buffer.headers['access-control-allow-origin'], origin)
5410+
}
5411+
5412+
// GET /in.js from each forbidden origin
5413+
for (const origin of forbiddenOrigins) {
5414+
const buffer = await fetch(result.hosts[0], result.port, '/in.js', { headers: { Origin: origin } })
5415+
assert.strictEqual(buffer.toString(), `console.log(123);\n`);
5416+
assert.strictEqual(fs.readFileSync(input, 'utf8'), `console.log(123)`)
5417+
assert.strictEqual(buffer.headers['access-control-allow-origin'], undefined)
5418+
}
5419+
} finally {
5420+
await context.dispose();
5421+
}
5422+
},
53085423
}
53095424

53105425
async function futureSyntax(esbuild, js, targetBelow, targetAbove) {

0 commit comments

Comments
 (0)