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

feat: introduce remote loader for missing files with esbuild #274

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
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
139 changes: 139 additions & 0 deletions internal/esbuild/remote_loader_plugin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package esbuild

import (
"context"
"crypto/md5"
"fmt"
"github.com/FriendsOfShopware/shopware-cli/internal/system"
"github.com/evanw/esbuild/pkg/api"
"io"
"net/http"
"os"
"path"
"path/filepath"
"regexp"
)

const RemoteLoaderName = "sw-remote-loader"

type RemoteLoaderOptions struct {
BaseUrl string
Matchers map[string]RemoteLoaderReplacer
}

type RemoteLoaderReplacer struct {
Matching *regexp.Regexp
Replace string
}

func newRemoteLoaderPlugin(ctx context.Context, options RemoteLoaderOptions) api.Plugin {
return api.Plugin{
Name: RemoteLoaderName,
Setup: func(build api.PluginBuild) {
for matcher, matcherOptions := range options.Matchers {
build.OnResolve(api.OnResolveOptions{Filter: matcher}, func(args api.OnResolveArgs) (api.OnResolveResult, error) {
file := options.BaseUrl + matcherOptions.Matching.ReplaceAllString(args.Path, matcherOptions.Replace)

ext := filepath.Ext(file)

// When we have a file extension, try direct load. But maybe the file has two file extensions, therefore, we need to fallback to .js/.ts
if ext != "" {
if content, err := fetchRemoteAsset(ctx, file); err == nil {
return api.OnResolveResult{
Path: content,
}, nil
}
}

// Try to load the file with .ts and .js extension
if content, err := fetchRemoteAsset(ctx, file+".ts"); err == nil {
return api.OnResolveResult{
Path: content,
}, nil
}

if content, err := fetchRemoteAsset(ctx, file+".js"); err == nil {
return api.OnResolveResult{
Path: content,
}, nil
}

return api.OnResolveResult{}, fmt.Errorf("could not load file %s", file)
})
}

build.OnResolve(api.OnResolveOptions{Filter: "deepmerge"}, func(args api.OnResolveArgs) (api.OnResolveResult, error) {
if file, err := fetchRemoteAsset(ctx, "https://unpkg.com/[email protected]"); err == nil {
return api.OnResolveResult{
Path: file,
}, nil
}

return api.OnResolveResult{}, fmt.Errorf("could not load file %s", args.Path)
})
},
}
}

func fetchRemoteAsset(ctx context.Context, url string) (string, error) {
assetDir := path.Join(system.GetShopwareCliCacheDir(), "assets")

if _, err := os.Stat(assetDir); os.IsNotExist(err) {
if err := os.MkdirAll(assetDir, 0755); err != nil {
return "", err
}
}

cacheFile := path.Join(assetDir, fmt.Sprintf("%x", md5.Sum([]byte(url))))

ext := filepath.Ext(url)

// Only add file extension for those files. Required because of HTTP requests to unpkg.com
if ext == ".css" || ext == ".scss" {
cacheFile += ext
}

cacheMissFile := cacheFile + ".miss"

if _, err := os.Stat(cacheFile); err == nil {
return cacheFile, nil
}

if _, err := os.Stat(cacheMissFile); err == nil {
return "", fmt.Errorf("file does not exists")
}

r, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}

r.Header.Add("User-Agent", "Shopware CLI")

resp, err := http.DefaultClient.Do(r)
if err != nil {
return "", err
}

if resp.StatusCode != 200 {
_ = os.WriteFile(cacheMissFile, []byte{}, 0644)

return "", fmt.Errorf("file does not exists")
}

content, err := io.ReadAll(resp.Body)

if err != nil {
return "", err
}

if err := resp.Body.Close(); err != nil {
return "", err
}

if err := os.WriteFile(cacheFile, content, 0644); err != nil {
return "", err
}

return cacheFile, nil
}
125 changes: 125 additions & 0 deletions internal/esbuild/remote_loader_plugin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package esbuild

import (
"context"
"github.com/evanw/esbuild/pkg/api"
"github.com/stretchr/testify/assert"
"os"
"path"
"regexp"
"testing"
)

func TestLoadRegularJSFileWithImportPrefixAdmin(t *testing.T) {
tmpDir := t.TempDir()

filePath := createEntrypoint(tmpDir, "import {searchRankingPoint} from \"@administration/app/service/search-ranking.service\";")

options := RemoteLoaderOptions{
BaseUrl: "https://raw.githubusercontent.com/shopware/shopware/v6.5.7.3/src/Administration/Resources/app/administration/",
Matchers: map[string]RemoteLoaderReplacer{
"^@administration/": {Matching: regexp.MustCompile("^@administration/"), Replace: "src/"},
},
}

build := api.BuildOptions{
EntryPoints: []string{filePath},
Plugins: []api.Plugin{newRemoteLoaderPlugin(context.Background(), options)},
Outfile: "extension.js",
Bundle: true,
Write: false,
}

result := api.Build(build)
assert.Len(t, result.Errors, 0)

assert.Contains(t, string(result.OutputFiles[0].Contents), "HIGH_SEARCH_RANKING")
}

func createEntrypoint(tmpDir, content string) string {
filePath := tmpDir + "/test.js"

_ = os.WriteFile(path.Join(tmpDir, "test.js"), []byte(content), 0644)

return filePath
}

func TestLoadRegularJSFileAdmin(t *testing.T) {
tmpDir := t.TempDir()

filePath := createEntrypoint(tmpDir, "import {searchRankingPoint} from \"src/app/service/search-ranking.service\";")

options := RemoteLoaderOptions{
BaseUrl: "https://raw.githubusercontent.com/shopware/shopware/v6.5.7.3/src/Administration/Resources/app/administration/",
Matchers: map[string]RemoteLoaderReplacer{
"^src\\/": {Matching: regexp.MustCompile("^src/"), Replace: "src/"},
},
}

build := api.BuildOptions{
EntryPoints: []string{filePath},
Plugins: []api.Plugin{newRemoteLoaderPlugin(context.Background(), options)},
Outfile: "extension.js",
Bundle: true,
Write: false,
}

result := api.Build(build)
assert.Len(t, result.Errors, 0)

assert.Len(t, result.OutputFiles, 1)

assert.Contains(t, string(result.OutputFiles[0].Contents), "HIGH_SEARCH_RANKING")
}

func TestLoadSCSSFromExternalSource(t *testing.T) {
tmpDir := t.TempDir()

filePath := createEntrypoint(tmpDir, "import \"src/module/sw-cms/component/sw-cms-block/sw-cms-block.scss\";")

options := RemoteLoaderOptions{
BaseUrl: "https://raw.githubusercontent.com/shopware/shopware/v6.5.7.3/src/Administration/Resources/app/administration/",
Matchers: map[string]RemoteLoaderReplacer{
"^src\\/": {Matching: regexp.MustCompile("^src/"), Replace: "src/"},
},
}

build := api.BuildOptions{
EntryPoints: []string{filePath},
Plugins: []api.Plugin{newScssPlugin(context.Background()), newRemoteLoaderPlugin(context.Background(), options)},
Outfile: "extension.js",
Bundle: true,
Write: false,
}

result := api.Build(build)
assert.Len(t, result.Errors, 0)
}

func TestLoadRegularJSFileStorefront(t *testing.T) {
tmpDir := t.TempDir()

filePath := createEntrypoint(tmpDir, "import Plugin from \"src/plugin-system/plugin.class\"; class Foo extends Plugin {}; export {Foo}")

options := RemoteLoaderOptions{
BaseUrl: "https://raw.githubusercontent.com/shopware/shopware/v6.5.7.3/src/Storefront/Resources/app/storefront/",
Matchers: map[string]RemoteLoaderReplacer{
"^src\\/": {Matching: regexp.MustCompile("^src/"), Replace: "src/"},
},
}

build := api.BuildOptions{
EntryPoints: []string{filePath},
Plugins: []api.Plugin{newRemoteLoaderPlugin(context.Background(), options)},
Outfile: "extension.js",
Bundle: true,
Write: false,
}

result := api.Build(build)
assert.Len(t, result.Errors, 0)

assert.Len(t, result.OutputFiles, 1)

assert.Contains(t, string(result.OutputFiles[0].Contents), "There is no valid element given")
}
Loading