Skip to content

Commit

Permalink
Support custom landing page (esm-dev#928)
Browse files Browse the repository at this point in the history
  • Loading branch information
ije authored Nov 23, 2024
1 parent fef1d90 commit 1fe7f20
Show file tree
Hide file tree
Showing 6 changed files with 146 additions and 54 deletions.
16 changes: 9 additions & 7 deletions HOSTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand Down
37 changes: 24 additions & 13 deletions config.example.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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",

Expand All @@ -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/",

Expand All @@ -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
Expand All @@ -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"],
Expand Down
85 changes: 58 additions & 27 deletions server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"strings"

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

var (
Expand All @@ -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 {
Expand Down Expand Up @@ -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())
}
Expand Down Expand Up @@ -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")
Expand Down
7 changes: 3 additions & 4 deletions server/esm_router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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") {
Expand Down Expand Up @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions server/npm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
51 changes: 50 additions & 1 deletion server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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),
)

Expand Down Expand Up @@ -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
}
}

Expand Down

0 comments on commit 1fe7f20

Please sign in to comment.