Skip to content
Files & File Serving

Files & File Serving

The wfs package provides an injectable filesystem that can be read from and written to, and the fileserver package serves files over HTTP with an SPA-style fallback.

Registering a filesystem

wfs.RegisterLocal(ctx, path) registers a lazy singleton fs.FS rooted at a directory path, and wfs.NewLocalFS(root) builds the filesystem value directly. The generated template uses it for user-uploaded files:

kernel.Register(func(ctx context.Context, c *config.Config) {
	wfs.RegisterLocal(ctx, c.FileSystem)
})

The Config.FileSystem field is a plain path, wired in config.go:

FileSystem: "./files",

Inside a handler, inject fs.FS and use it like any Go filesystem:

type UploadHandler struct {
	FS fs.FS `inject:""`
}

func (h *UploadHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	data, err := fs.ReadFile(h.FS, r.PathValue("path"))
	// ...
}

Reading and writing

fs.FS is read-only. wfs layers optional write operations on top of it through small interfaces, and the package-level functions dispatch to whichever one the filesystem implements:

interfacemethod
WriteFileFSWriteFile(name string, b []byte) error
OpenFileFSOpenFile(name string, flag int) (File, error)
MkdirFSMkdir(name string) error
RemoveFSRemove(name string) error
RenameFSRename(oldname, newname string) error

Call the matching wfs.WriteFile, wfs.OpenFile, wfs.Mkdir, wfs.Remove, or wfs.Rename function rather than asserting the interface yourself. A filesystem that does not support the operation returns an error wrapping wfs.ErrNotImplemented:

fsys := wfs.NewLocalFS("./files")
err := wfs.WriteFile(fsys, "report.txt", []byte("done"))

wfs.NewLocalFS returns a *LocalFS that implements every interface above, plus the standard Open, Stat, ReadFile, ReadDir, and ReadLink reads. OpenFile takes the same os flags through the wfs.O_* constants — O_RDONLY, O_WRONLY, O_RDWR, O_APPEND, O_CREATE, O_EXCL, O_SYNC, and O_TRUNC — and returns a wfs.File, an fs.File that is also an io.Writer.

Serving static files with SPA fallback

fileserver.WithFallback(root, basePath, fallbackPath, data) returns an http.Handler that serves files from an fs.FS with a catch-all route. When the requested path matches a real file, its content is served with the correct MIME type; otherwise the fallback template is rendered with the provided data.

This is the standard pattern for serving a compiled frontend:

r.Handle("/assets", fileserver.WithFallback(resources.Content, "dist", "index.html", nil))

basePath is prepended to every URL path before looking up the file, so embedding files under dist/ and mapping /assets to the handler serves dist/style.css at /assets/style.css. Directories and missing paths both fall through to fallbackPath, which receives the data value as template data.

Build-tag split

The generated project uses //go:build !dev and //go:build dev tags in resources/ to swap between a production embed.FS and a development os.DirFS. When running spice dev the dev tag is set, so file changes are picked up without rebuilding the binary.