assets
packageAPI reference for the assets
package.
Imports
(19)errors
STD
net/http
STD
sync
INT
github.com/rfwlab/rfw/v2/http
INT
github.com/rfwlab/rfw/v2/js
STD
context
STD
io
INT
github.com/rfwlab/rfw/v2/internal/safehttp
STD
net/http/httptest
STD
strings
STD
testing
STD
time
STD
encoding/json
STD
fmt
STD
io/fs
STD
os
STD
path/filepath
INT
github.com/rfwlab/rfw/v2/cmd/rfw/logging
INT
github.com/rfwlab/rfw/v2/cmd/rfw/plugins
SetNativeClient
SetNativeClient has no effect in browser builds.
Parameters
func SetNativeClient(_ *stdhttp.Client)
{}
LoadImage
LoadImage asynchronously loads an image from url.
While loading it returns http.ErrPending.
Results are cached by URL.
Parameters
Returns
func LoadImage(url string) (js.Value, error)
{
ceIface, _ := imageCache.LoadOrStore(url, &imageEntry{ready: make(chan struct{})})
ce := ceIface.(*imageEntry)
ce.once.Do(func() {
go loadImageFn(url, func(v js.Value, err error) {
ce.img = v
ce.err = err
close(ce.ready)
})
})
select {
case <-ce.ready:
if ce.err != nil {
return js.Value{}, ce.err
}
return ce.img, nil
default:
return js.Value{}, http.ErrPending
}
}
modelEntry
modelEntry holds the result of a binary load.
type modelEntry struct
Fields
| Name | Type | Description |
|---|---|---|
| once | sync.Once | |
| data | []byte | |
| err | error | |
| ready | chan struct{} |
LoadModel
LoadModel fetches binary data from url. It caches results and
returns http.ErrPending while the request is in flight.
Parameters
Returns
func LoadModel(url string) ([]byte, error)
{
ceIface, _ := modelCache.LoadOrStore(url, &modelEntry{ready: make(chan struct{})})
ce := ceIface.(*modelEntry)
ce.once.Do(func() {
go loadBinaryFn(url, func(b []byte, err error) {
ce.data = b
ce.err = err
close(ce.ready)
})
})
select {
case <-ce.ready:
if ce.err != nil {
return nil, ce.err
}
return ce.data, nil
default:
return nil, http.ErrPending
}
}
LoadJSON
LoadJSON delegates to http.FetchJSON and shares its caching behavior.
Parameters
Returns
func LoadJSON(url string, v any) error
{ return http.FetchJSON(url, v) }
ClearCache
ClearCache removes cached assets for url.
Parameters
func ClearCache(url string)
{
imageCache.Delete(url)
modelCache.Delete(url)
}
SetNativeClient
SetNativeClient replaces the native asset client. Custom clients may reach
private networks and must only receive trusted URLs. Passing nil restores
the default client, which rejects private network addresses.
Parameters
func SetNativeClient(client *stdhttp.Client)
{
if client == nil {
client = safehttp.NewClient()
}
httpClientMu.Lock()
httpClient = client
httpClientMu.Unlock()
}
currentNativeClient
Returns
func currentNativeClient() *stdhttp.Client
{
httpClientMu.RLock()
client := httpClient
httpClientMu.RUnlock()
return client
}
fetch
Parameters
Returns
func fetch(url string) (*stdhttp.Response, error)
{
req, err := safehttp.NewRequest(context.Background(), stdhttp.MethodGet, url)
if err != nil {
return nil, err
}
return currentNativeClient().Do(req)
}
Image
Image is a placeholder for non-WASM builds.
type Image struct
Fields
| Name | Type | Description |
|---|---|---|
| URL | string | |
| Data | []byte |
imageEntry
type imageEntry struct
Uses
LoadImage
LoadImage starts or reads a cached image request.
Parameters
Returns
func LoadImage(url string) (Image, error)
{
ceIface, _ := imageCache.LoadOrStore(url, &imageEntry{ready: make(chan struct{})})
ce := ceIface.(*imageEntry)
ce.once.Do(func() {
go loadImageFn(url, func(v Image, err error) {
ce.img = v
ce.err = err
close(ce.ready)
})
})
select {
case <-ce.ready:
if ce.err != nil {
return Image{}, ce.err
}
return ce.img, nil
default:
return Image{}, http.ErrPending
}
}
Uses
modelEntry
type modelEntry struct
Fields
| Name | Type | Description |
|---|---|---|
| once | sync.Once | |
| data | []byte | |
| err | error | |
| ready | chan struct{} |
LoadModel
LoadModel starts or reads a cached binary model request.
Parameters
Returns
func LoadModel(url string) ([]byte, error)
{
ceIface, _ := modelCache.LoadOrStore(url, &modelEntry{ready: make(chan struct{})})
ce := ceIface.(*modelEntry)
ce.once.Do(func() {
go loadBinaryFn(url, func(b []byte, err error) {
ce.data = b
ce.err = err
close(ce.ready)
})
})
select {
case <-ce.ready:
if ce.err != nil {
return nil, ce.err
}
return ce.data, nil
default:
return nil, http.ErrPending
}
}
LoadJSON
LoadJSON retrieves and decodes JSON from url.
Parameters
Returns
func LoadJSON(url string, v any) error
{ return http.FetchJSON(url, v) }
ClearCache
ClearCache removes cached assets for url.
Parameters
func ClearCache(url string)
{
imageCache.Delete(url)
modelCache.Delete(url)
}
waitImage
func waitImage(t *testing.T, fn func() (Image, error)) Image
{
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
img, err := fn()
if err == nil {
return img
}
if err != v1http.ErrPending {
t.Fatalf("unexpected error: %v", err)
}
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for image")
}
time.Sleep(5 * time.Millisecond)
}
}
Uses
waitBytes
Parameters
Returns
func waitBytes(t *testing.T, fn func() ([]byte, error)) []byte
{
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
b, err := fn()
if err == nil {
return b
}
if err != v1http.ErrPending {
t.Fatalf("unexpected error: %v", err)
}
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for bytes")
}
time.Sleep(5 * time.Millisecond)
}
}
TestFetchRejectsNonHTTPURLs
Parameters
func TestFetchRejectsNonHTTPURLs(t *testing.T)
{
for _, rawURL := range []string{"file:///tmp/secret", "/relative", "http://127.0.0.1/secret"} {
resp, err := fetch(rawURL)
if resp != nil {
if closeErr := resp.Body.Close(); closeErr != nil {
t.Fatalf("close response for %q: %v", rawURL, closeErr)
}
}
if err == nil {
t.Fatalf("expected %q to be rejected", rawURL)
}
if strings.HasPrefix(rawURL, "http://127.") && !strings.Contains(err.Error(), "not public") {
t.Fatalf("unexpected private URL rejection: %v", err)
}
}
}
TestLoadModel_CacheAndPending
Parameters
func TestLoadModel_CacheAndPending(t *testing.T)
{
var hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hits++
time.Sleep(20 * time.Millisecond)
w.WriteHeader(200)
_, _ = w.Write([]byte{1, 2, 3})
}))
defer srv.Close()
oldClient := currentNativeClient()
SetNativeClient(srv.Client())
t.Cleanup(func() { SetNativeClient(oldClient) })
ClearCache(srv.URL)
t.Cleanup(func() { ClearCache(srv.URL) })
if _, err := LoadModel(srv.URL); err != v1http.ErrPending {
t.Fatalf("expected ErrPending, got %v", err)
}
got := waitBytes(t, func() ([]byte, error) { return LoadModel(srv.URL) })
if len(got) != 3 || got[0] != 1 || got[2] != 3 {
t.Fatalf("unexpected bytes: %v", got)
}
got2, err := LoadModel(srv.URL)
if err != nil {
t.Fatalf("expected cached success, got %v", err)
}
if len(got2) != 3 || got2[1] != 2 {
t.Fatalf("unexpected cached bytes: %v", got2)
}
if hits != 1 {
t.Fatalf("expected 1 server hit, got %d", hits)
}
}
TestLoadImage_UsesCache
Parameters
func TestLoadImage_UsesCache(t *testing.T)
{
var hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hits++
time.Sleep(15 * time.Millisecond)
w.WriteHeader(200)
_, _ = w.Write([]byte("PNGDATA"))
}))
defer srv.Close()
oldClient := currentNativeClient()
SetNativeClient(srv.Client())
t.Cleanup(func() { SetNativeClient(oldClient) })
ClearCache(srv.URL)
t.Cleanup(func() { ClearCache(srv.URL) })
if _, err := LoadImage(srv.URL); err != v1http.ErrPending {
t.Fatalf("expected ErrPending, got %v", err)
}
img := waitImage(t, func() (Image, error) { return LoadImage(srv.URL) })
if img.URL != srv.URL || string(img.Data) != "PNGDATA" {
t.Fatalf("unexpected image: %+v", img)
}
img2, err := LoadImage(srv.URL)
if err != nil {
t.Fatalf("expected cached image, got %v", err)
}
if string(img2.Data) != "PNGDATA" {
t.Fatalf("unexpected cached data: %q", string(img2.Data))
}
if hits != 1 {
t.Fatalf("expected 1 hit, got %d", hits)
}
}
TestLoadJSONRejectsPrivateNetwork
Parameters
func TestLoadJSONRejectsPrivateNetwork(t *testing.T)
{
const privateURL = "http://127.0.0.1/data.json"
v1http.ClearCache(privateURL)
t.Cleanup(func() { v1http.ClearCache(privateURL) })
var out struct {
V int `json:"v"`
}
if err := LoadJSON(privateURL, &out); err != v1http.ErrPending {
t.Fatalf("expected ErrPending, got %v", err)
}
deadline := time.Now().Add(2 * time.Second)
for {
err := LoadJSON(privateURL, &out)
if err != nil && err != v1http.ErrPending {
if !strings.Contains(err.Error(), "not public") {
t.Fatalf("unexpected private URL rejection: %v", err)
}
return
}
if time.Now().After(deadline) {
t.Fatal("timed out waiting for private URL rejection")
}
time.Sleep(5 * time.Millisecond)
}
}
plugin
type plugin struct
Methods
Parameters
Returns
func (*plugin) Build(raw json.RawMessage) (err error)
{
cfg := struct {
Dir string `json:"dir"`
Dest string `json:"dest"`
}{
Dir: "assets",
Dest: "dist",
}
if len(raw) > 0 {
if err := json.Unmarshal(raw, &cfg); err != nil {
return fmt.Errorf("decode assets plugin config: %w", err)
}
}
sourcePath, err := projectDirectory(cfg.Dir)
if err != nil {
return fmt.Errorf("invalid assets source: %w", err)
}
destinationPath, err := projectDirectory(cfg.Dest)
if err != nil {
return fmt.Errorf("invalid assets destination: %w", err)
}
p.src = sourcePath
p.dst = destinationPath
projectRoot, err := os.OpenRoot(".")
if err != nil {
return err
}
defer func() {
if closeErr := projectRoot.Close(); err == nil {
err = closeErr
}
}()
if err := projectRoot.MkdirAll(destinationPath, 0o755); err != nil {
return err
}
sourceRoot, err := projectRoot.OpenRoot(sourcePath)
if err != nil {
return err
}
defer func() {
if closeErr := sourceRoot.Close(); err == nil {
err = closeErr
}
}()
targetRoot, err := projectRoot.OpenRoot(destinationPath)
if err != nil {
return err
}
defer func() {
if closeErr := targetRoot.Close(); err == nil {
err = closeErr
}
}()
err = fs.WalkDir(sourceRoot.FS(), ".", func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
if path == "." {
return nil
}
if err := targetRoot.MkdirAll(path, 0o755); err != nil {
return err
}
return nil
}
in, err := sourceRoot.Open(path)
if err != nil {
return err
}
if err := targetRoot.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return errors.Join(err, in.Close())
}
out, err := targetRoot.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err != nil {
return errors.Join(err, in.Close())
}
_, copyErr := io.Copy(out, in)
outCloseErr := out.Close()
inCloseErr := in.Close()
if err := errors.Join(copyErr, outCloseErr, inCloseErr); err != nil {
return err
}
logging.Log.Info("copied file", logging.F("plugin", "assets"), logging.F("path", filepath.Join(destinationPath, path)))
return nil
})
return err
}
Parameters
Returns
func (*plugin) ShouldRebuild(path string) bool
{
relative, err := filepath.Rel(p.src, path)
return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
}
Fields
| Name | Type | Description |
|---|---|---|
| src | string | |
| dst | string |
init
func init()
{ plugins.Register(&plugin{}) }
projectDirectory
Parameters
Returns
func projectDirectory(path string) (string, error)
{
if path == "" || filepath.IsAbs(path) {
return "", fmt.Errorf("path %q must be project-relative", path)
}
cleaned := filepath.Clean(path)
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("path %q escapes the project", path)
}
return cleaned, nil
}
TestBuildAndShouldRebuild
TestBuildAndShouldRebuild verifies that the assets plugin copies files and
reports rebuild requirements for files within the source directory.
Parameters
func TestBuildAndShouldRebuild(t *testing.T)
{
p := &plugin{}
tmp := t.TempDir()
t.Chdir(tmp)
src := "assets"
dest := "dist"
if err := os.MkdirAll(filepath.Join(src, "img"), 0o750); err != nil {
t.Fatalf("mkdir: %v", err)
}
srcFile := filepath.Join(src, "img", "logo.png")
if err := os.WriteFile(srcFile, []byte("data"), 0o600); err != nil {
t.Fatalf("write src: %v", err)
}
cfg := struct {
Dir string `json:"dir"`
Dest string `json:"dest"`
}{Dir: src, Dest: dest}
raw, err := json.Marshal(cfg)
if err != nil {
t.Fatal(err)
}
if err := p.Build(raw); err != nil {
t.Fatalf("Build: %v", err)
}
if data, err := os.ReadFile("dist/img/logo.png"); err != nil || string(data) != "data" {
t.Fatalf("expected copied file, got %v %q", err, data)
}
if !p.ShouldRebuild(srcFile) {
t.Fatalf("expected ShouldRebuild true for %s", srcFile)
}
if p.ShouldRebuild(filepath.Join(tmp, "other")) {
t.Fatalf("unexpected rebuild for unrelated file")
}
}
TestBuildRejectsPathsOutsideProject
Parameters
func TestBuildRejectsPathsOutsideProject(t *testing.T)
{
p := &plugin{}
for _, cfg := range []struct {
Dir string `json:"dir"`
Dest string `json:"dest"`
}{
{Dir: "../assets", Dest: "dist"},
{Dir: "assets", Dest: "../dist"},
} {
raw, err := json.Marshal(cfg)
if err != nil {
t.Fatal(err)
}
if err := p.Build(raw); err == nil {
t.Errorf("expected config %+v to be rejected", cfg)
}
}
}