Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support custom landing page #928

Merged
merged 1 commit into from
Nov 23, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions HOSTING.md
Original file line number Diff line number Diff line change
@@ -75,21 +75,23 @@ docker run -p 8080:8080 \

Available environment variables:

- `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`.
- `CUSTOM_LANDING_PAGE_ORIGIN`: The custom landing page origin, default is empty.
- `CUSTOM_LANDING_PAGE_ASSETS`: The custom landing page assets separated by comma(,), default is empty.
- `CORS_ALLOW_ORIGINS`: The CORS allow origins separated by comma(,), default is allow all origins.
- `LOG_LEVEL`: The log level, available values are ["debug", "info", "warn", "error"], default is "info".
- `MINIFY`: Minify the built JS/CSS files, default is `true`.
- `NPM_QUERY_CACHE_TTL`: The cache TTL for NPM query, default is 10 minutes.
- `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.
- `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.
- `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`:

37 changes: 24 additions & 13 deletions config.example.jsonc
Original file line number Diff line number Diff line change
@@ -17,6 +17,15 @@
// The wait time for incoming requests to wait for the build process to finish, default is 30 seconds.
"buildWaitTime": 30,

// compress http response body with gzip/brotli, default is true.
"compress": true,

// Enable minify for built js/css files, default is true,
"minify": true,

// generate source map for built js/css files, default is true.
"sourceMap": true,

// The storage options.
// Examples:
// - Use local file system as the storage:
@@ -52,6 +61,17 @@
// a S3-compatible storage.
"cacheRawFile": false,

// The custom landing page, the server will proxy the `/` path to the `origin` if it's provided.
// If your custom landing page owns assets, you also need to provide the asset paths in the `assets` field.
"customLandingPage": {
"origin": "https://example.com",
"assets": [
"favicon.ico",
"assets/app.js",
"assets/app.css"
]
},

// The work directory for the server, default is "~/.esmd".
"workDir": "~/.esmd",

@@ -61,6 +81,9 @@
// The log level, available values are ["debug", "info", "warn", "error"], default is "info".
"logLevel": "info",

// The cache TTL for npm packages query, default is 600 seconds (10 minutes).
"npmQueryCacheTTL": 600,

// The global npm registry, default is "https://registry.npmjs.org/".
"npmRegistry": "https://registry.npmjs.org/",

@@ -74,7 +97,7 @@

// Registries for scoped packages. This will ensure packages with these scopes get downloaded
// from specific registry, default is empty.
"npmRegistries": {
"npmScopedRegistries": {
"@scope_name": {
"registry": "https://your-registry.com/",
// add access token for authentication
@@ -85,18 +108,6 @@
}
},

// The cache TTL for npm packages query, default is 600 seconds (10 minutes).
"npmQueryCacheTTL": 600,

// compress http response body with gzip/brotli, default is true.
"compress": true,

// Enable minify for built js/css files, default is true,
"minify": true,

// generate source map for built js/css files, default is true.
"sourceMap": true,

// The list to ban some packages or scopes, default no ban.
"banList": {
"packages": ["@scope_name/package_name"],
85 changes: 58 additions & 27 deletions server/config.go
Original file line number Diff line number Diff line change
@@ -13,6 +13,7 @@ import (
"strings"

"github.com/esm-dev/esm.sh/server/storage"
"github.com/ije/gox/term"
)

var (
@@ -22,30 +23,36 @@ var (

// Config represents the configuration of esm.sh server.
type Config struct {
Port uint16 `json:"port"`
TlsPort uint16 `json:"tlsPort"`
WorkDir string `json:"workDir"`
CorsAllowOrigins []string `json:"corsAllowOrigins"`
AllowList AllowList `json:"allowList"`
BanList BanList `json:"banList"`
BuildConcurrency uint16 `json:"buildConcurrency"`
BuildWaitTime uint16 `json:"buildWaitTime"`
Storage storage.StorageOptions `json:"storage"`
CacheRawFile bool `json:"cacheRawFile"`
LogDir string `json:"logDir"`
LogLevel string `json:"logLevel"`
NpmRegistry string `json:"npmRegistry"`
NpmToken string `json:"npmToken"`
NpmUser string `json:"npmUser"`
NpmPassword string `json:"npmPassword"`
NpmRegistries map[string]NpmRegistry `json:"npmRegistries"`
NpmQueryCacheTTL uint32 `json:"npmQueryCacheTTL"`
MinifyRaw json.RawMessage `json:"minify"`
SourceMapRaw json.RawMessage `json:"sourceMap"`
CompressRaw json.RawMessage `json:"compress"`
Minify bool `json:"-"`
SourceMap bool `json:"-"`
Compress bool `json:"-"`
Port uint16 `json:"port"`
TlsPort uint16 `json:"tlsPort"`
CustomLandingPage LandingPageOptions `json:"customLandingPage"`
WorkDir string `json:"workDir"`
CorsAllowOrigins []string `json:"corsAllowOrigins"`
AllowList AllowList `json:"allowList"`
BanList BanList `json:"banList"`
BuildConcurrency uint16 `json:"buildConcurrency"`
BuildWaitTime uint16 `json:"buildWaitTime"`
Storage storage.StorageOptions `json:"storage"`
CacheRawFile bool `json:"cacheRawFile"`
LogDir string `json:"logDir"`
LogLevel string `json:"logLevel"`
NpmRegistry string `json:"npmRegistry"`
NpmToken string `json:"npmToken"`
NpmUser string `json:"npmUser"`
NpmPassword string `json:"npmPassword"`
NpmScopedRegistries map[string]NpmRegistry `json:"npmScopedRegistries"`
NpmQueryCacheTTL uint32 `json:"npmQueryCacheTTL"`
MinifyRaw json.RawMessage `json:"minify"`
SourceMapRaw json.RawMessage `json:"sourceMap"`
CompressRaw json.RawMessage `json:"compress"`
Minify bool `json:"-"`
SourceMap bool `json:"-"`
Compress bool `json:"-"`
}

type LandingPageOptions struct {
Origin string `json:"origin"`
Assets []string `json:"assets"`
}

type BanList struct {
@@ -124,6 +131,30 @@ func normalizeConfig(c *Config) {
}
}
}
if c.CustomLandingPage.Origin == "" {
v := os.Getenv("CUSTOM_LANDING_PAGE_ORIGIN")
if v != "" {
c.CustomLandingPage.Origin = v
if v := os.Getenv("CUSTOM_LANDING_PAGE_ASSETS"); v != "" {
a := strings.Split(v, ",")
for _, p := range a {
p = strings.TrimSpace(p)
if p != "" {
c.CustomLandingPage.Assets = append(c.CustomLandingPage.Assets, p)
}
}
}
}
}
if origin := c.CustomLandingPage.Origin; origin != "" {
u, err := url.Parse(origin)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
fmt.Println(term.Red("[error] invalid custom landing page origin: " + origin))
c.CustomLandingPage = LandingPageOptions{}
} else {
c.CustomLandingPage.Origin = u.Scheme + "://" + u.Host
}
}
if c.BuildConcurrency == 0 {
c.BuildConcurrency = uint16(runtime.NumCPU())
}
@@ -183,17 +214,17 @@ func normalizeConfig(c *Config) {
if c.NpmPassword == "" {
c.NpmPassword = os.Getenv("NPM_PASSWORD")
}
if len(c.NpmRegistries) > 0 {
if len(c.NpmScopedRegistries) > 0 {
regs := make(map[string]NpmRegistry)
for scope, rc := range c.NpmRegistries {
for scope, rc := range c.NpmScopedRegistries {
if strings.HasPrefix(scope, "@") && isHttpSepcifier(rc.Registry) {
rc.Registry = strings.TrimRight(rc.Registry, "/") + "/"
regs[scope] = rc
} else {
fmt.Printf("[error] invalid npm registry for scope %s: %s\n", scope, rc.Registry)
}
}
c.NpmRegistries = regs
c.NpmScopedRegistries = regs
}
if c.NpmQueryCacheTTL == 0 {
v := os.Getenv("NPM_QUERY_CACHE_TTL")
7 changes: 3 additions & 4 deletions server/esm_router.go
Original file line number Diff line number Diff line change
@@ -27,8 +27,8 @@ import (
)

const (
ccMustRevalidate = "public, max-age=0, must-revalidate"
cc1day = "public, max-age=86400"
ccMustRevalidate = "public, max-age=0, must-revalidate"
ccImmutable = "public, max-age=31536000, immutable"
ctJavaScript = "application/javascript; charset=utf-8"
ctTypeScript = "application/typescript; charset=utf-8"
@@ -44,7 +44,7 @@ func esmRouter(debug bool) rex.Handle {
)

return func(ctx *rex.Context) any {
pathname := ctx.Pathname()
pathname := ctx.R.URL.Path

// ban malicious requests
if strings.HasPrefix(pathname, "/.") || strings.HasSuffix(pathname, ".php") {
@@ -234,8 +234,7 @@ func esmRouter(debug bool) rex.Handle {
ctx.SetHeader("Content-Type", ctJavaScript)
return `throw new Error("[esm.sh] The deno CLI has been deprecated, please use our vscode extension instead: https://marketplace.visualstudio.com/items?itemName=ije.esm-vscode")`
}
ifNoneMatch := ctx.GetHeader("If-None-Match")
if ifNoneMatch != "" && ifNoneMatch == globalETag {
if ctx.GetHeader("If-None-Match") == globalETag {
return rex.Status(http.StatusNotModified, nil)
}
indexHTML, err := embedFS.ReadFile("server/embed/index.html")
4 changes: 2 additions & 2 deletions server/npm.go
Original file line number Diff line number Diff line change
@@ -223,8 +223,8 @@ func NewNpmRcFromConfig() *NpmRC {
},
},
}
if len(config.NpmRegistries) > 0 {
for scope, reg := range config.NpmRegistries {
if len(config.NpmScopedRegistries) > 0 {
for scope, reg := range config.NpmScopedRegistries {
rc.Registries[scope] = NpmRegistry{
Registry: reg.Registry,
Token: reg.Token,
51 changes: 50 additions & 1 deletion server/server.go
Original file line number Diff line number Diff line change
@@ -7,7 +7,9 @@ import (
"os"
"os/signal"
"path"
"strings"
"syscall"
"time"

"github.com/esm-dev/esm.sh/server/storage"
logger "github.com/ije/gox/log"
@@ -127,6 +129,7 @@ func Serve(efs EmbedFS) {
rex.Header("Server", "esm.sh"),
rex.Optional(rex.Compress(), config.Compress),
cors(config.CorsAllowOrigins),
rex.Optional(customLandingPage(&config.CustomLandingPage), config.CustomLandingPage.Origin != ""),
esmRouter(debug),
)

@@ -179,7 +182,53 @@ func cors(allowOrigins []string) rex.Handle {
if isOptionsMethod {
return rex.NoContent()
}
return nil
return nil // next
}
}

func customLandingPage(options *LandingPageOptions) rex.Handle {
assets := NewStringSet()
for _, p := range options.Assets {
assets.Add("/" + strings.TrimPrefix(p, "/"))
}
return func(ctx *rex.Context) any {
if ctx.R.URL.Path == "/" || assets.Has(ctx.R.URL.Path) {
query := ctx.R.URL.RawQuery
if query != "" {
query = "?" + query
}
res, err := http.Get(options.Origin + ctx.R.URL.Path + query)
if err != nil {
return rex.Err(http.StatusBadGateway, "Failed to fetch custom landing page")
}
etag := res.Header.Get("Etag")
if etag != "" {
if ctx.GetHeader("If-None-Match") == etag {
return rex.Status(http.StatusNotModified, nil)
}
ctx.SetHeader("Etag", etag)
} else {
lastModified := res.Header.Get("Last-Modified")
if lastModified != "" {
v := ctx.GetHeader("If-Modified-Since")
if v != "" {
timeIfModifiedSince, e1 := time.Parse(http.TimeFormat, v)
timeLastModified, e2 := time.Parse(http.TimeFormat, lastModified)
if e1 == nil && e2 == nil && !timeIfModifiedSince.After(timeLastModified) {
return rex.Status(http.StatusNotModified, nil)
}
}
ctx.SetHeader("Last-Modified", lastModified)
}
}
cacheCache := res.Header.Get("Cache-Control")
if cacheCache == "" {
ctx.SetHeader("Cache-Control", ccMustRevalidate)
}
ctx.SetHeader("Content-Type", res.Header.Get("Content-Type"))
return res.Body // auto closed
}
return nil // next
}
}