Skip to content

Commit 7573ed4

Browse files
committed
web: add dl filesystem
1 parent 359e195 commit 7573ed4

File tree

2 files changed

+335
-0
lines changed

2 files changed

+335
-0
lines changed

web/dl/dl.go

Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
//go:build js && wasm
2+
3+
package dl
4+
5+
import (
6+
"io"
7+
"os"
8+
"path"
9+
"sync"
10+
"syscall/js"
11+
"time"
12+
13+
"tractor.dev/wanix/fs"
14+
"tractor.dev/wanix/fs/fskit"
15+
)
16+
17+
func Download(data []byte, filename string) error {
18+
buf := js.Global().Get("Uint8Array").New(len(data))
19+
js.CopyBytesToJS(buf, data)
20+
blob := js.Global().Get("Blob").New([]any{buf}, js.ValueOf(map[string]any{"type": "application/octet-stream"}))
21+
url := js.Global().Get("URL").Call("createObjectURL", blob)
22+
doc := js.Global().Get("document")
23+
a := doc.Call("createElement", "a")
24+
a.Set("href", url)
25+
a.Set("download", filename)
26+
a.Call("click")
27+
js.Global().Get("URL").Call("revokeObjectURL", url)
28+
return nil
29+
}
30+
31+
// FS is a virtual filesystem that triggers browser downloads on file close.
32+
// Files written to this filesystem don't persist - instead, when closed,
33+
// their contents are downloaded in the browser with the filename.
34+
type FS struct {
35+
mu sync.Mutex
36+
files map[string]*dlFile
37+
}
38+
39+
// New creates a new download filesystem.
40+
func New() *FS {
41+
return &FS{
42+
files: make(map[string]*dlFile),
43+
}
44+
}
45+
46+
var (
47+
_ fs.FS = (*FS)(nil)
48+
_ fs.CreateFS = (*FS)(nil)
49+
_ fs.OpenFileFS = (*FS)(nil)
50+
)
51+
52+
func (fsys *FS) Open(name string) (fs.File, error) {
53+
if !fs.ValidPath(name) {
54+
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
55+
}
56+
57+
name = path.Clean(name)
58+
59+
if name == "." {
60+
// Return root directory
61+
return fskit.DirFile(fskit.Entry(".", fs.ModeDir|0755, time.Now())), nil
62+
}
63+
64+
// Check if there's an open file being written
65+
fsys.mu.Lock()
66+
f, exists := fsys.files[name]
67+
fsys.mu.Unlock()
68+
69+
if exists {
70+
// Return a read view of the file being written
71+
f.mu.Lock()
72+
dataCopy := make([]byte, len(f.data))
73+
copy(dataCopy, f.data)
74+
f.mu.Unlock()
75+
return &dlReadFile{
76+
name: name,
77+
data: dataCopy,
78+
}, nil
79+
}
80+
81+
// Files don't persist, so return not found
82+
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
83+
}
84+
85+
func (fsys *FS) Stat(name string) (fs.FileInfo, error) {
86+
if !fs.ValidPath(name) {
87+
return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrNotExist}
88+
}
89+
90+
name = path.Clean(name)
91+
92+
if name == "." {
93+
return fskit.Entry(".", fs.ModeDir|0755, time.Now()), nil
94+
}
95+
96+
// Files don't persist
97+
return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrNotExist}
98+
}
99+
100+
func (fsys *FS) Create(name string) (fs.File, error) {
101+
if !fs.ValidPath(name) {
102+
return nil, &fs.PathError{Op: "create", Path: name, Err: fs.ErrInvalid}
103+
}
104+
105+
name = path.Clean(name)
106+
107+
if name == "." {
108+
return nil, &fs.PathError{Op: "create", Path: name, Err: fs.ErrInvalid}
109+
}
110+
111+
f := &dlFile{
112+
fsys: fsys,
113+
name: name,
114+
data: nil,
115+
}
116+
117+
fsys.mu.Lock()
118+
fsys.files[name] = f
119+
fsys.mu.Unlock()
120+
121+
return f, nil
122+
}
123+
124+
func (fsys *FS) OpenFile(name string, flag int, perm fs.FileMode) (fs.File, error) {
125+
if !fs.ValidPath(name) {
126+
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}
127+
}
128+
129+
name = path.Clean(name)
130+
131+
// Root directory
132+
if name == "." {
133+
return fskit.DirFile(fskit.Entry(".", fs.ModeDir|0755, time.Now())), nil
134+
}
135+
136+
// For any write operation, create a new download file
137+
if flag&(os.O_WRONLY|os.O_RDWR|os.O_CREATE) != 0 {
138+
return fsys.Create(name)
139+
}
140+
141+
// Read-only: check if file is currently being written
142+
fsys.mu.Lock()
143+
f, exists := fsys.files[name]
144+
fsys.mu.Unlock()
145+
146+
if exists {
147+
f.mu.Lock()
148+
dataCopy := make([]byte, len(f.data))
149+
copy(dataCopy, f.data)
150+
f.mu.Unlock()
151+
return &dlReadFile{
152+
name: name,
153+
data: dataCopy,
154+
}, nil
155+
}
156+
157+
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
158+
}
159+
160+
// dlFile is a file that buffers writes and triggers a download on close.
161+
type dlFile struct {
162+
fsys *FS
163+
name string
164+
data []byte
165+
offset int64
166+
closed bool
167+
mu sync.Mutex
168+
}
169+
170+
func (f *dlFile) Stat() (fs.FileInfo, error) {
171+
f.mu.Lock()
172+
defer f.mu.Unlock()
173+
return fskit.Entry(f.name, 0644, time.Now(), int64(len(f.data))), nil
174+
}
175+
176+
func (f *dlFile) Read(p []byte) (int, error) {
177+
f.mu.Lock()
178+
defer f.mu.Unlock()
179+
if f.closed {
180+
return 0, fs.ErrClosed
181+
}
182+
if f.offset >= int64(len(f.data)) {
183+
return 0, io.EOF
184+
}
185+
n := copy(p, f.data[f.offset:])
186+
f.offset += int64(n)
187+
return n, nil
188+
}
189+
190+
func (f *dlFile) Write(p []byte) (int, error) {
191+
f.mu.Lock()
192+
defer f.mu.Unlock()
193+
if f.closed {
194+
return 0, fs.ErrClosed
195+
}
196+
197+
n := len(p)
198+
endPos := f.offset + int64(n)
199+
200+
// Grow data slice if needed
201+
if endPos > int64(len(f.data)) {
202+
newData := make([]byte, endPos)
203+
copy(newData, f.data)
204+
f.data = newData
205+
}
206+
207+
copy(f.data[f.offset:], p)
208+
f.offset += int64(n)
209+
return n, nil
210+
}
211+
212+
func (f *dlFile) Seek(offset int64, whence int) (int64, error) {
213+
f.mu.Lock()
214+
defer f.mu.Unlock()
215+
if f.closed {
216+
return 0, fs.ErrClosed
217+
}
218+
219+
var newOffset int64
220+
switch whence {
221+
case io.SeekStart:
222+
newOffset = offset
223+
case io.SeekCurrent:
224+
newOffset = f.offset + offset
225+
case io.SeekEnd:
226+
newOffset = int64(len(f.data)) + offset
227+
default:
228+
return 0, &fs.PathError{Op: "seek", Path: f.name, Err: fs.ErrInvalid}
229+
}
230+
231+
if newOffset < 0 {
232+
return 0, &fs.PathError{Op: "seek", Path: f.name, Err: fs.ErrInvalid}
233+
}
234+
235+
f.offset = newOffset
236+
return newOffset, nil
237+
}
238+
239+
func (f *dlFile) WriteAt(p []byte, off int64) (int, error) {
240+
f.mu.Lock()
241+
defer f.mu.Unlock()
242+
if f.closed {
243+
return 0, fs.ErrClosed
244+
}
245+
if off < 0 {
246+
return 0, &fs.PathError{Op: "writeat", Path: f.name, Err: fs.ErrInvalid}
247+
}
248+
249+
n := len(p)
250+
endPos := off + int64(n)
251+
252+
// Grow data slice if needed
253+
if endPos > int64(len(f.data)) {
254+
newData := make([]byte, endPos)
255+
copy(newData, f.data)
256+
f.data = newData
257+
}
258+
259+
copy(f.data[off:], p)
260+
return n, nil
261+
}
262+
263+
func (f *dlFile) ReadAt(p []byte, off int64) (int, error) {
264+
f.mu.Lock()
265+
defer f.mu.Unlock()
266+
if f.closed {
267+
return 0, fs.ErrClosed
268+
}
269+
if off < 0 || off >= int64(len(f.data)) {
270+
return 0, io.EOF
271+
}
272+
n := copy(p, f.data[off:])
273+
if n < len(p) {
274+
return n, io.EOF
275+
}
276+
return n, nil
277+
}
278+
279+
func (f *dlFile) Close() error {
280+
f.mu.Lock()
281+
defer f.mu.Unlock()
282+
283+
if f.closed {
284+
return fs.ErrClosed
285+
}
286+
f.closed = true
287+
288+
// Remove from tracking
289+
f.fsys.mu.Lock()
290+
delete(f.fsys.files, f.name)
291+
f.fsys.mu.Unlock()
292+
293+
// Trigger download with the buffered data
294+
if len(f.data) > 0 {
295+
Download(f.data, path.Base(f.name))
296+
}
297+
298+
return nil
299+
}
300+
301+
func (f *dlFile) Sync() error {
302+
// No-op for download files - data is only committed on close
303+
return nil
304+
}
305+
306+
// dlReadFile is a read-only view of a file being written.
307+
type dlReadFile struct {
308+
name string
309+
data []byte
310+
offset int
311+
closed bool
312+
}
313+
314+
func (f *dlReadFile) Stat() (fs.FileInfo, error) {
315+
return fskit.Entry(f.name, 0644, time.Now(), int64(len(f.data))), nil
316+
}
317+
318+
func (f *dlReadFile) Read(p []byte) (int, error) {
319+
if f.closed {
320+
return 0, fs.ErrClosed
321+
}
322+
if f.offset >= len(f.data) {
323+
return 0, io.EOF
324+
}
325+
n := copy(p, f.data[f.offset:])
326+
f.offset += n
327+
return n, nil
328+
}
329+
330+
func (f *dlReadFile) Close() error {
331+
f.closed = true
332+
return nil
333+
}

web/web.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"tractor.dev/wanix/vfs/pipe"
2020
"tractor.dev/wanix/vm"
2121
"tractor.dev/wanix/web/caches"
22+
"tractor.dev/wanix/web/dl"
2223
"tractor.dev/wanix/web/dom"
2324
"tractor.dev/wanix/web/fsa"
2425
"tractor.dev/wanix/web/runtime"
@@ -35,6 +36,7 @@ func New(k *wanix.K) fskit.MapFS {
3536
"caches": caches.New(),
3637
"worker": workerfs,
3738
"opfs": opfs,
39+
"dl": dl.New(),
3840
}
3941
if !runtime.Instance().Get("_sw").IsUndefined() {
4042
webfs["sw"] = sw.Activate(runtime.Instance().Get("_sw"), k)

0 commit comments

Comments
 (0)