Skip to content

Commit

Permalink
Rewrite CORS middleware (esm-dev#922)
Browse files Browse the repository at this point in the history
  • Loading branch information
ije authored Nov 23, 2024
1 parent 6b875eb commit 4a80b8c
Show file tree
Hide file tree
Showing 12 changed files with 101 additions and 47 deletions.
10 changes: 5 additions & 5 deletions HOSTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,21 +75,21 @@ docker run -p 8080:8080 \

Available environment variables:

- `AUTH_SECRET`: The server auth secret, default is no authorization.
- `CORS_ALLOW_ORIGINS`: The CORS allow origins separated by comma(,), default is allow all origins.
- `COMPRESS`: Compress http responses with gzip/brotli, default is `true`.
- `MINIFY`: Minify the built JS/CSS files, default is `true`.
- `SOURCEMAP`: Generate source map for built JS/CSS files, default is `true`.
- `STORAGE_TYPE`: The storage type, available values are ["fs", "s3"], default is "fs".
- `STORAGE_ENDPOINT`: The storage endpoint, default is "~/.esmd/storage".
- `STORAGE_REGION`: The region for S3 storage.
- `STORAGE_ACCESS_KEY_ID`: The access key for S3 storage.
- `STORAGE_SECRET_ACCESS_KEY`: The secret key for S3 storage.
- `COMPRESS`: Compress http responses with gzip/brotli, default is `true`.
- `MINIFY`: Minify the built JS/CSS files, default is `true`.
- `SOURCEMAP`: Generate source map for built JS/CSS files, default is `true`.
- `LOG_LEVEL`: The log level, available values are ["debug", "info", "warn", "error"], default is "info".
- `NPM_REGISTRY`: The global NPM registry, default is "https://registry.npmjs.org/".
- `NPM_TOKEN`: The access token for the global NPM registry.
- `NPM_USER`: The access user for the global NPM registry.
- `NPM_PASSWORD`: The access password for the global NPM registry.
- `NPM_QUERY_CACHE_TTL`: The cache TTL for NPM query, default is 10 minutes.
- `LOG_LEVEL`: The log level, available values are ["debug", "info", "warn", "error"], default is "info".

You can also create your own Dockerfile based on `ghcr.io/esm-dev/esm.sh`:

Expand Down
5 changes: 3 additions & 2 deletions config.example.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
// Note: if you are running the server in a docker container, you need to expose the port as `443:443`.
"tlsPort": 0,

// The secret token to validate the `Authorization: Bearer <secret>` header of incoming requests, default is disabled.
"authSecret": "",
// allowed cors origins, default is allow all origins.
// Note: A valid origin must be a valid URL, including the protocol, domain, and port. e.g. "https://example.com".
"corsAllowOrigins": [],

// The concurrency number for the build process, default equals to the number of CPU cores.
"buildConcurrency": 0,
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ require (
github.com/gorilla/websocket v1.5.3
github.com/ije/esbuild-internal v0.24.0
github.com/ije/gox v0.9.1
github.com/ije/rex v1.13.6
github.com/ije/rex v1.13.7
github.com/yuin/goldmark v1.7.8
github.com/yuin/goldmark-meta v1.1.0
golang.org/x/net v0.31.0
Expand All @@ -21,5 +21,5 @@ require (
golang.org/x/crypto v0.29.0 // indirect
golang.org/x/sys v0.27.0 // indirect
golang.org/x/text v0.20.0 // indirect
gopkg.in/yaml.v2 v2.3.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ github.com/ije/esbuild-internal v0.24.0 h1:ekQilnTP0YQBpqHwaZwpIWH/YbehY4xctKW8g
github.com/ije/esbuild-internal v0.24.0/go.mod h1:s7HvKZ4ZGifyzvgWpSwnJOQTr6b+bsgfNBZ8HAEwwSM=
github.com/ije/gox v0.9.1 h1:w+hVBqo/e8+g1VUxfikHlIJrcevHhB6WswvPzpGO0w4=
github.com/ije/gox v0.9.1/go.mod h1:3GTaK8WXf6oxRbrViLqKNLTNcMR871Dz0zoujFNmG48=
github.com/ije/rex v1.13.6 h1:rEI5rGPgwKV11Gu8x9d/9FCv0TD9G8HQXTb13Xb2g/w=
github.com/ije/rex v1.13.6/go.mod h1:aG6Vr9Ks4l1QJTAFoIeMpf6gkHcqY+Os4hklq9+V6v4=
github.com/ije/rex v1.13.7 h1:8K2LyED+GB2+cxPcOGXJ+Ptyw+xfI6UNNg05SlTjEG8=
github.com/ije/rex v1.13.7/go.mod h1:81UCOz0rEwIjgKFwzRlEjMJcB9BtJSOs0v8k9p5z4lY=
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
Expand All @@ -33,5 +33,5 @@ golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
4 changes: 2 additions & 2 deletions server/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ func withCache[T any](key string, cacheTtl time.Duration, fetch func() (T, error
func init() {
// cache GC
go func() {
tick := time.NewTicker(10 * time.Minute)
for {
time.Sleep(10 * time.Minute)
now := time.Now()
now := <-tick.C
expKeys := []any{}
cacheStore.Range(func(key, value any) bool {
item := value.(*CacheItem)
Expand Down
15 changes: 12 additions & 3 deletions server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"net/url"
"os"
"path"
"path/filepath"
Expand All @@ -24,7 +25,7 @@ type Config struct {
Port uint16 `json:"port"`
TlsPort uint16 `json:"tlsPort"`
WorkDir string `json:"workDir"`
AuthSecret string `json:"authSecret"`
CorsAllowOrigins []string `json:"corsAllowOrigins"`
AllowList AllowList `json:"allowList"`
BanList BanList `json:"banList"`
BuildConcurrency uint16 `json:"buildConcurrency"`
Expand Down Expand Up @@ -112,8 +113,16 @@ func normalizeConfig(c *Config) {
if c.Port == 0 {
c.Port = 80
}
if c.AuthSecret == "" {
c.AuthSecret = os.Getenv("AUTH_SECRET")
if v := os.Getenv("CORS_ALLOW_ORIGINS"); v != "" {
for _, p := range strings.Split(v, ",") {
orig := strings.TrimSpace(p)
if orig != "" {
u, e := url.Parse(orig)
if e == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" {
c.CorsAllowOrigins = append(c.CorsAllowOrigins, u.Scheme+"://"+u.Host)
}
}
}
}
if c.BuildConcurrency == 0 {
c.BuildConcurrency = uint16(runtime.NumCPU())
Expand Down
45 changes: 34 additions & 11 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package server
import (
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"path"
Expand Down Expand Up @@ -123,16 +124,9 @@ func Serve(efs EmbedFS) {
rex.Use(
rex.Logger(log),
rex.AccessLogger(accessLogger),
rex.Cors(rex.CorsOptions{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"HEAD", "GET", "POST"},
ExposedHeaders: []string{"X-Esm-Path", "X-TypeScript-Types"},
MaxAge: 86400, // 24 hours
AllowCredentials: false,
}),
rex.Header("Server", "esm.sh"),
rex.Optional(rex.Compress(), config.Compress),
auth(config.AuthSecret),
cors(config.CorsAllowOrigins),
esmRouter(debug),
)

Expand Down Expand Up @@ -162,11 +156,40 @@ func Serve(efs EmbedFS) {
accessLogger.FlushBuffer()
}

func auth(secret string) rex.Handle {
func cors(allowOrigins []string) rex.Handle {
allowList := NewStringSet(allowOrigins...)
return func(ctx *rex.Context) any {
if secret != "" && ctx.R.Header.Get("Authorization") != "Bearer "+secret {
return rex.Status(401, "Unauthorized")
origin := ctx.GetHeader("Origin")
isOptionsMethod := ctx.R.Method == "OPTIONS"
h := ctx.W.Header()
if allowList.Len() > 0 {
if origin != "" {
if !allowList.Has(origin) {
return rex.Status(403, "forbidden")
}
setCorsHeaders(h, isOptionsMethod, origin)
} else if isOptionsMethod {
// not a preflight request
return rex.Status(405, "method not allowed")
}
appendVaryHeader(h, "Origin")
} else {
setCorsHeaders(h, isOptionsMethod, "*")
}
if isOptionsMethod {
return rex.Status(204, nil)
}
return nil
}
}

func setCorsHeaders(h http.Header, isOptionsMethod bool, origin string) {
h.Set("Access-Control-Allow-Origin", origin)
h.Set("Access-Control-Allow-Methods", "HEAD, GET, POST")
if isOptionsMethod {
h.Set("Access-Control-Allow-Headers", "*")
h.Set("Access-Control-Max-Age", "86400")
} else {
h.Set("Access-Control-Expose-Headers", "X-Esm-Path, X-Typescript-Types")
}
}
8 changes: 4 additions & 4 deletions test/build-args/target.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,25 +43,25 @@ Deno.test("target from query", async () => {
{
const res = await fetch("http://localhost:8080/[email protected]?target=denonext");
assertEquals(res.status, 200);
assert(!res.headers.get("Vary")!.includes("User-Agent"));
assert(!res.headers.get("Vary")?.includes("User-Agent"));
assertStringIncludes(await res.text(), "/denonext/");
}
{
const res = await fetch("http://localhost:8080/[email protected]?target=deno");
assertEquals(res.status, 200);
assert(!res.headers.get("Vary")!.includes("User-Agent"));
assert(!res.headers.get("Vary")?.includes("User-Agent"));
assertStringIncludes(await res.text(), "/deno/");
}
{
const res = await fetch("http://localhost:8080/[email protected]?target=node");
assertEquals(res.status, 200);
assert(!res.headers.get("Vary")!.includes("User-Agent"));
assert(!res.headers.get("Vary")?.includes("User-Agent"));
assertStringIncludes(await res.text(), "/node/");
}
{
const res = await fetch("http://localhost:8080/[email protected]?target=es2024");
assertEquals(res.status, 200);
assert(!res.headers.get("Vary")!.includes("User-Agent"));
assert(!res.headers.get("Vary")?.includes("User-Agent"));
assertStringIncludes(await res.text(), "/es2024/");
}
});
37 changes: 29 additions & 8 deletions test/cors/cors.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,33 @@
import { assertEquals } from "jsr:@std/assert";

Deno.test("CORS", async () => {
const res = await fetch("http://localhost:8080/[email protected]", {
headers: {
"Origin": "https://example.com",
},
});
res.body?.cancel();
assertEquals(res.headers.get("Access-Control-Allow-Origin"), "*");
assertEquals(res.headers.get("Access-Control-Expose-Headers"), "X-Esm-Path, X-Typescript-Types");
{
const res = await fetch("http://localhost:8080/[email protected]", { method: "OPTIONS" });
res.body?.cancel();
assertEquals(res.status, 204);
assertEquals(res.headers.get("Access-Control-Allow-Origin"), "*");
assertEquals(res.headers.get("Access-Control-Allow-Methods"), "HEAD, GET, POST");
assertEquals(res.headers.get("Access-Control-Allow-Headers"), "*");
assertEquals(res.headers.get("Access-Control-Max-Age"), "86400");
}
{
const res = await fetch("http://localhost:8080/[email protected]");
res.body?.cancel();
assertEquals(res.headers.get("Access-Control-Allow-Origin"), "*");
assertEquals(res.headers.get("Access-Control-Allow-Methods"), "HEAD, GET, POST");
assertEquals(res.headers.get("Access-Control-Expose-Headers"), "X-Esm-Path, X-Typescript-Types");
assertEquals(res.headers.get("Vary"), "User-Agent");
}
{
const res = await fetch("http://localhost:8080/[email protected]", {
headers: {
"Origin": "https://example.com",
},
});
res.body?.cancel();
assertEquals(res.headers.get("Access-Control-Allow-Origin"), "*");
assertEquals(res.headers.get("Access-Control-Allow-Methods"), "HEAD, GET, POST");
assertEquals(res.headers.get("Access-Control-Expose-Headers"), "X-Esm-Path, X-Typescript-Types");
assertEquals(res.headers.get("Vary"), "User-Agent");
}
});
8 changes: 4 additions & 4 deletions test/fix-url/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Deno.test("query as version suffix", async () => {
assertEquals(res.status, 200);
assertEquals(res.headers.get("cache-control"), "public, max-age=31536000, immutable");
assertEquals(res.headers.get("content-type"), "application/javascript; charset=utf-8");
assert(!res.headers.get("vary")!.includes("User-Agent"));
assert(!res.headers.get("vary")?.includes("User-Agent"));
assertStringIncludes(code, "/[email protected]/es2022/client.development.mjs");
});

Expand All @@ -16,7 +16,7 @@ Deno.test("`/jsx-runtime` in query", async () => {
assertEquals(res.status, 200);
assertEquals(res.headers.get("cache-control"), "public, max-age=31536000, immutable");
assertEquals(res.headers.get("content-type"), "application/javascript; charset=utf-8");
assert(!res.headers.get("vary")!.includes("User-Agent"));
assert(!res.headers.get("vary")?.includes("User-Agent"));
assertStringIncludes(code, "/[email protected]/es2022/jsx-runtime.development.mjs");
});

Expand All @@ -28,7 +28,7 @@ Deno.test("redirect semantic versioning module for deno target", async () => {
assertEquals(res.status, 302);
assertEquals(res.headers.get("cache-control"), "public, max-age=600");
assertStringIncludes(res.headers.get("location")!, "http://localhost:8080/preact@");
assertStringIncludes(res.headers.get("vary")!, "User-Agent");
assertStringIncludes(res.headers.get("vary") ?? "", "User-Agent");
}

"browser target";
Expand All @@ -38,7 +38,7 @@ Deno.test("redirect semantic versioning module for deno target", async () => {
assertEquals(res.status, 200);
assertEquals(res.headers.get("cache-control"), "public, max-age=600");
assertEquals(res.headers.get("content-type"), "application/javascript; charset=utf-8");
assertStringIncludes(res.headers.get("vary")!, "User-Agent");
assertStringIncludes(res.headers.get("vary") ?? "", "User-Agent");
assertStringIncludes(code, "/preact@");
assertStringIncludes(code, "/es2022/");
}
Expand Down
2 changes: 1 addition & 1 deletion test/issue-420/test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { assertStringIncludes } from "jsr:@std/assert";

import { html } from "http://localhost:8080/htm/[email protected]";
import { useState } from "http://localhost:8080/[email protected]/hooks";
import { html } from "http://localhost:8080/htm/[email protected]";
import renderToString from "http://localhost:8080/[email protected][email protected]";

Deno.test("issue #420", () => {
Expand Down
2 changes: 1 addition & 1 deletion test/npm-replacements/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ Deno.test("npm replacements", async () => {
assertEquals(res.status, 200);
assertEquals(res.headers.get("cache-control"), "public, max-age=31536000, immutable");
assertEquals(res.headers.get("content-type"), "application/javascript; charset=utf-8");
assert(!res.headers.get("vary")!.includes("User-Agent"));
assert(!res.headers.get("vary")?.includes("User-Agent"));
assert(!code.includes("import")); // should not have import statements
});

0 comments on commit 4a80b8c

Please sign in to comment.