Files
shiftcraft/.claude/skills/pocketbase-best-practices/rules/ext-filesystem.md
2026-04-17 23:26:01 +00:00

94 lines
3.7 KiB
Markdown

---
title: Always Close the Filesystem Handle Returned by NewFilesystem
impact: HIGH
impactDescription: Leaked filesystem clients keep S3 connections and file descriptors open until the process exits
tags: filesystem, extending, files, s3, NewFilesystem, close
---
## Always Close the Filesystem Handle Returned by NewFilesystem
`app.NewFilesystem()` (Go) and `$app.newFilesystem()` (JS) return a filesystem client backed by either the local disk or S3, depending on the app settings. **The caller owns the handle** and must close it - there is no finalizer and no automatic pooling. Leaking handles leaks TCP connections to S3 and file descriptors on disk, and eventually the server will stop accepting uploads.
PocketBase also ships a second client: `app.NewBackupsFilesystem()` for the backups bucket/directory, with the same ownership rules.
**Incorrect (no close, raw bytes buffered in memory):**
```go
// ❌ Forgets to close fs - connection leaks
func downloadAvatar(app core.App, key string) ([]byte, error) {
fs, err := app.NewFilesystem()
if err != nil {
return nil, err
}
// ❌ no defer fs.Close()
// ❌ GetFile loads the whole file into a reader; reading it all into a
// byte slice defeats streaming for large files
r, err := fs.GetFile(key)
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
}
```
**Correct (defer Close, stream to the HTTP response):**
```go
func serveAvatar(app core.App, key string) echo.HandlerFunc {
return func(e *core.RequestEvent) error {
fs, err := app.NewFilesystem()
if err != nil {
return e.InternalServerError("filesystem init failed", err)
}
defer fs.Close() // REQUIRED
// Serve directly from the filesystem - handles ranges, content-type,
// and the X-Accel-Redirect / X-Sendfile headers when available
return fs.Serve(e.Response, e.Request, key, "avatar.jpg")
}
}
// Uploading a local file to the PocketBase-managed filesystem
func importAvatar(app core.App, record *core.Record, path string) error {
f, err := filesystem.NewFileFromPath(path)
if err != nil {
return err
}
record.Set("avatar", f) // assignment + app.Save() persist it
return app.Save(record)
}
```
```javascript
// JSVM - file factories live on the $filesystem global
const file1 = $filesystem.fileFromPath("/tmp/import.jpg");
const file2 = $filesystem.fileFromBytes(new Uint8Array([0xff, 0xd8]), "logo.jpg");
const file3 = $filesystem.fileFromURL("https://example.com/a.jpg");
// Assigning to a record field triggers upload on save
record.set("avatar", file1);
$app.save(record);
// Low-level client - MUST be closed
const fs = $app.newFilesystem();
try {
const list = fs.list("thumbs/");
for (const obj of list) {
console.log(obj.key, obj.size);
}
} finally {
fs.close(); // REQUIRED
}
```
**Rules:**
- `defer fs.Close()` **immediately** after a successful `NewFilesystem()` / `NewBackupsFilesystem()` call (Go). In JS, wrap in `try { ... } finally { fs.close() }`.
- Prefer the high-level record-field API (`record.Set("field", file)` + `app.Save`) over direct `fs.Upload` calls - it handles thumbs regeneration, orphan cleanup, and hook integration.
- File factory functions (`filesystem.NewFileFromPath`, `NewFileFromBytes`, `NewFileFromURL` / JS `$filesystem.fileFromPath|Bytes|URL`) capture their input; they do not stream until save.
- `fileFromURL` performs an HTTP GET and loads the body into memory - not appropriate for large files.
- Do not share a single long-lived `fs` across unrelated requests; the object is cheap to create per request.
Reference: [Go Filesystem](https://pocketbase.io/docs/go-filesystem/) · [JS Filesystem](https://pocketbase.io/docs/js-filesystem/)