21d0f11d85
Send: accept a ReadableStreamDefaultReader instead of a Uint8Array. jsStreamReader (new io.ReadCloser) awaits reader.read() Promises via the channel+FuncOf pattern, feeding chunks directly to the HTTP PUT body. No js.CopyBytesToGo of the full file. Receive: openWaitingFile now returns a pull-based ReadableStream backed by the Go io.ReadCloser (jsReadableStream helper). Each pull call reads up to 64 KiB and enqueues a Uint8Array chunk; no io.ReadAll. jsFileOps.OpenReader: JS now returns a ReadableStream instead of a Uint8Array; Go wraps it in jsStreamReader for streaming delivery. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
511 lines
14 KiB
Go
511 lines
14 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
// This file bridges the Taildrop FileOps interface to JS callbacks,
|
|
// using the same channel+FuncOf pattern as the Go stdlib's WASM HTTP
|
|
// transport (src/net/http/roundtrip_js.go): Go passes a js.FuncOf to JS,
|
|
// then blocks on a channel until JS calls it back — which may be async.
|
|
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"syscall/js"
|
|
"time"
|
|
|
|
"tailscale.com/client/tailscale/apitype"
|
|
"tailscale.com/feature/taildrop"
|
|
"tailscale.com/ipn"
|
|
"tailscale.com/ipn/ipnlocal"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/util/progresstracking"
|
|
"tailscale.com/util/rands"
|
|
)
|
|
|
|
// Compile-time check that jsFileOps implements taildrop.FileOps.
|
|
var _ taildrop.FileOps = (*jsFileOps)(nil)
|
|
|
|
// taildropExt returns the taildrop extension, or an error if unavailable.
|
|
func (i *jsIPN) taildropExt() (*taildrop.Extension, error) {
|
|
ext, ok := ipnlocal.GetExt[*taildrop.Extension](i.lb)
|
|
if !ok {
|
|
return nil, errors.New("taildrop extension not available")
|
|
}
|
|
return ext, nil
|
|
}
|
|
|
|
// listFileTargets returns the peers that can receive Taildrop files as a JSON
|
|
// array of {stableNodeID, name, addresses, os} objects.
|
|
func (i *jsIPN) listFileTargets() js.Value {
|
|
return makePromise(func() (any, error) {
|
|
ext, err := i.taildropExt()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
fts, err := ext.FileTargets()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
type jsTarget struct {
|
|
StableNodeID string `json:"stableNodeID"`
|
|
Name string `json:"name"`
|
|
Addresses []string `json:"addresses"`
|
|
OS string `json:"os"`
|
|
}
|
|
out := make([]jsTarget, 0, len(fts))
|
|
for _, ft := range fts {
|
|
addrs := make([]string, 0, len(ft.Node.Addresses))
|
|
for _, a := range ft.Node.Addresses {
|
|
addrs = append(addrs, a.Addr().String())
|
|
}
|
|
out = append(out, jsTarget{
|
|
StableNodeID: string(ft.Node.StableID),
|
|
Name: ft.Node.Name,
|
|
Addresses: addrs,
|
|
OS: ft.Node.Hostinfo.OS(),
|
|
})
|
|
}
|
|
b, err := json.Marshal(out)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return string(b), nil
|
|
})
|
|
}
|
|
|
|
// sendFile sends stream as filename to the peer identified by stableNodeID,
|
|
// reporting progress via notifyOutgoingFiles callbacks roughly once per second.
|
|
// declaredSize is the total byte count (-1 if unknown); it is used for progress
|
|
// reporting and sets Content-Length on the PUT request (chunked TE when -1).
|
|
func (i *jsIPN) sendFile(stableNodeID, filename string, stream js.Value, declaredSize int) js.Value {
|
|
return makePromise(func() (any, error) {
|
|
ext, err := i.taildropExt()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
fts, err := ext.FileTargets()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var ft *apitype.FileTarget
|
|
for _, x := range fts {
|
|
if x.Node.StableID == tailcfg.StableNodeID(stableNodeID) {
|
|
ft = x
|
|
break
|
|
}
|
|
}
|
|
if ft == nil {
|
|
return nil, fmt.Errorf("node %q not found or not a file target", stableNodeID)
|
|
}
|
|
dstURL, err := url.Parse(ft.PeerAPIURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("bogus peer URL: %w", err)
|
|
}
|
|
|
|
reader := stream.Call("getReader")
|
|
body := &jsStreamReader{reader: reader}
|
|
|
|
outgoing := &ipn.OutgoingFile{
|
|
ID: rands.HexString(30),
|
|
PeerID: tailcfg.StableNodeID(stableNodeID),
|
|
Name: filename,
|
|
DeclaredSize: int64(declaredSize),
|
|
Started: time.Now(),
|
|
}
|
|
updates := map[string]*ipn.OutgoingFile{outgoing.ID: outgoing}
|
|
|
|
// Report final state (success or failure) when the function returns.
|
|
var sendErr error
|
|
defer func() {
|
|
outgoing.Finished = true
|
|
outgoing.Succeeded = sendErr == nil
|
|
ext.UpdateOutgoingFiles(updates)
|
|
}()
|
|
|
|
progressBody := progresstracking.NewReader(body, time.Second, func(n int, _ error) {
|
|
outgoing.Sent = int64(n)
|
|
ext.UpdateOutgoingFiles(updates)
|
|
})
|
|
|
|
req, err := http.NewRequest("PUT", dstURL.String()+"/v0/put/"+url.PathEscape(filename), progressBody)
|
|
if err != nil {
|
|
sendErr = err
|
|
return nil, err
|
|
}
|
|
req.ContentLength = int64(declaredSize)
|
|
client := &http.Client{Transport: i.lb.Dialer().PeerAPITransport()}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
sendErr = err
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
b := make([]byte, len(respBody))
|
|
copy(b, respBody)
|
|
// trim trailing whitespace
|
|
for len(b) > 0 && (b[len(b)-1] == '\n' || b[len(b)-1] == '\r' || b[len(b)-1] == ' ') {
|
|
b = b[:len(b)-1]
|
|
}
|
|
sendErr = fmt.Errorf("send file: %s: %s", resp.Status, b)
|
|
return nil, sendErr
|
|
}
|
|
return nil, nil
|
|
})
|
|
}
|
|
|
|
// waitingFiles returns received files waiting for pickup as a JSON array of
|
|
// {name, size} objects. Always returns an array (never null).
|
|
func (i *jsIPN) waitingFiles() js.Value {
|
|
return makePromise(func() (any, error) {
|
|
ext, err := i.taildropExt()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
wfs, err := ext.WaitingFiles()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
type jsWaitingFile struct {
|
|
Name string `json:"name"`
|
|
Size int64 `json:"size"`
|
|
}
|
|
out := make([]jsWaitingFile, len(wfs))
|
|
for i, wf := range wfs {
|
|
out[i] = jsWaitingFile{Name: wf.Name, Size: wf.Size}
|
|
}
|
|
b, err := json.Marshal(out)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return string(b), nil
|
|
})
|
|
}
|
|
|
|
// openWaitingFile returns the contents of a received file as a ReadableStream.
|
|
// The stream emits Uint8Array chunks and closes when the file is fully read.
|
|
func (i *jsIPN) openWaitingFile(name string) js.Value {
|
|
return makePromise(func() (any, error) {
|
|
ext, err := i.taildropExt()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rc, _, err := ext.OpenFile(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return jsReadableStream(rc), nil
|
|
})
|
|
}
|
|
|
|
// deleteWaitingFile deletes a received file by name.
|
|
func (i *jsIPN) deleteWaitingFile(name string) js.Value {
|
|
return makePromise(func() (any, error) {
|
|
ext, err := i.taildropExt()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, ext.DeleteFile(name)
|
|
})
|
|
}
|
|
|
|
// wireTaildropFileOps installs a JS-backed FileOps on the taildrop extension
|
|
// if jsObj is a non-null JS object. It must be called after NewLocalBackend
|
|
// and before lb.Start (i.e. before run() is called by the user), so that the
|
|
// FileOps is in place when the extension's onChangeProfile hook fires on init.
|
|
//
|
|
// SetStagedFileOps is used instead of SetFileOps so that files are staged for
|
|
// explicit retrieval via WaitingFiles/OpenFile rather than delivered directly
|
|
// (DirectFileMode=false). The JS caller fetches them via waitingFiles() et al.
|
|
func wireTaildropFileOps(lb *ipnlocal.LocalBackend, jsObj js.Value) {
|
|
if jsObj.IsUndefined() || jsObj.IsNull() {
|
|
return
|
|
}
|
|
ext, ok := ipnlocal.GetExt[*taildrop.Extension](lb)
|
|
if !ok {
|
|
return
|
|
}
|
|
ext.SetStagedFileOps(&jsFileOps{v: jsObj})
|
|
}
|
|
|
|
// jsStreamReader implements io.ReadCloser by pulling chunks from a JS
|
|
// ReadableStreamDefaultReader. Each Read call awaits one reader.read() Promise,
|
|
// using the channel+FuncOf pattern so Go blocks until JS delivers the chunk.
|
|
type jsStreamReader struct {
|
|
reader js.Value
|
|
buf []byte
|
|
done bool
|
|
}
|
|
|
|
func (r *jsStreamReader) Read(p []byte) (int, error) {
|
|
if r.done {
|
|
return 0, io.EOF
|
|
}
|
|
if len(r.buf) > 0 {
|
|
n := copy(p, r.buf)
|
|
r.buf = r.buf[n:]
|
|
return n, nil
|
|
}
|
|
type chunkResult struct {
|
|
data []byte
|
|
done bool
|
|
}
|
|
ch := make(chan chunkResult, 1)
|
|
thenFn := js.FuncOf(func(this js.Value, args []js.Value) any {
|
|
result := args[0]
|
|
if result.Get("done").Bool() {
|
|
ch <- chunkResult{done: true}
|
|
} else {
|
|
value := result.Get("value")
|
|
b := make([]byte, value.Get("byteLength").Int())
|
|
js.CopyBytesToGo(b, value)
|
|
ch <- chunkResult{data: b}
|
|
}
|
|
return nil
|
|
})
|
|
defer thenFn.Release()
|
|
r.reader.Call("read").Call("then", thenFn)
|
|
result := <-ch
|
|
if result.done {
|
|
r.done = true
|
|
return 0, io.EOF
|
|
}
|
|
n := copy(p, result.data)
|
|
r.buf = result.data[n:]
|
|
return n, nil
|
|
}
|
|
|
|
func (r *jsStreamReader) Close() error {
|
|
r.reader.Call("cancel")
|
|
return nil
|
|
}
|
|
|
|
// jsReadableStream wraps rc in a pull-based JS ReadableStream. Each pull call
|
|
// reads up to 64 KiB from rc and enqueues a Uint8Array chunk; the stream
|
|
// closes on EOF or signals an error on any other read failure.
|
|
func jsReadableStream(rc io.ReadCloser) js.Value {
|
|
var pullFn, cancelFn js.Func
|
|
|
|
cancelFn = js.FuncOf(func(this js.Value, args []js.Value) any {
|
|
rc.Close()
|
|
pullFn.Release()
|
|
cancelFn.Release()
|
|
return nil
|
|
})
|
|
|
|
pullFn = js.FuncOf(func(this js.Value, args []js.Value) any {
|
|
controller := args[0]
|
|
var execFn js.Func
|
|
execFn = js.FuncOf(func(this js.Value, rr []js.Value) any {
|
|
resolve := rr[0]
|
|
go func() {
|
|
defer execFn.Release()
|
|
buf := make([]byte, 65536)
|
|
n, err := rc.Read(buf)
|
|
if n > 0 {
|
|
chunk := js.Global().Get("Uint8Array").New(n)
|
|
js.CopyBytesToJS(chunk, buf[:n])
|
|
controller.Call("enqueue", chunk)
|
|
}
|
|
if err == io.EOF {
|
|
rc.Close()
|
|
pullFn.Release()
|
|
cancelFn.Release()
|
|
controller.Call("close")
|
|
} else if err != nil {
|
|
rc.Close()
|
|
pullFn.Release()
|
|
cancelFn.Release()
|
|
controller.Call("error", err.Error())
|
|
}
|
|
resolve.Invoke()
|
|
}()
|
|
return nil
|
|
})
|
|
return js.Global().Get("Promise").New(execFn)
|
|
})
|
|
|
|
return js.Global().Get("ReadableStream").New(map[string]any{
|
|
"pull": pullFn,
|
|
"cancel": cancelFn,
|
|
})
|
|
}
|
|
|
|
// jsFileOps implements [taildrop.FileOps] by delegating to JS callbacks.
|
|
// JS methods use one of two callback conventions:
|
|
//
|
|
// Void ops (openWriter, write, closeWriter, remove): cb(err?: string)
|
|
//
|
|
// on success: cb() or cb("")
|
|
// on error: cb("error message")
|
|
// not found: cb("ENOENT")
|
|
//
|
|
// Result ops (rename, listFiles, stat, openReader): cb(result: T | null, err?: string)
|
|
//
|
|
// on success: cb(result)
|
|
// on error: cb(null, "error message")
|
|
// not found: cb(null, "ENOENT")
|
|
type jsFileOps struct {
|
|
v js.Value
|
|
}
|
|
|
|
// jsCallResult invokes method on j.v, appending a Go-owned js.FuncOf as the
|
|
// final argument. It blocks until JS calls back with (result, errStr?), then
|
|
// returns (result, error). An absent or empty errStr means success.
|
|
//
|
|
// JS convention for result ops: cb(result: T | null, err?: string)
|
|
func (j jsFileOps) jsCallResult(method string, args ...any) (js.Value, error) {
|
|
type result struct {
|
|
val js.Value
|
|
err error
|
|
}
|
|
ch := make(chan result, 1)
|
|
cb := js.FuncOf(func(this js.Value, cbArgs []js.Value) any {
|
|
var r result
|
|
if len(cbArgs) > 0 {
|
|
if t := cbArgs[0].Type(); t != js.TypeNull && t != js.TypeUndefined {
|
|
r.val = cbArgs[0]
|
|
}
|
|
}
|
|
if len(cbArgs) > 1 && cbArgs[1].Type() == js.TypeString {
|
|
if s := cbArgs[1].String(); s != "" {
|
|
r.err = errors.New(s)
|
|
}
|
|
}
|
|
ch <- r
|
|
return nil
|
|
})
|
|
defer cb.Release()
|
|
j.v.Call(method, append(args, cb)...)
|
|
r := <-ch
|
|
return r.val, r.err
|
|
}
|
|
|
|
// jsCallVoid invokes method on j.v for operations that return no result,
|
|
// appending a Go-owned js.FuncOf as the final argument. It blocks until JS
|
|
// calls back with an optional error string, then returns the error or nil.
|
|
//
|
|
// JS convention for void ops: cb(err?: string)
|
|
func (j jsFileOps) jsCallVoid(method string, args ...any) error {
|
|
ch := make(chan error, 1)
|
|
cb := js.FuncOf(func(this js.Value, cbArgs []js.Value) any {
|
|
var err error
|
|
if len(cbArgs) > 0 && cbArgs[0].Type() == js.TypeString {
|
|
if s := cbArgs[0].String(); s != "" {
|
|
err = errors.New(s)
|
|
}
|
|
}
|
|
ch <- err
|
|
return nil
|
|
})
|
|
defer cb.Release()
|
|
j.v.Call(method, append(args, cb)...)
|
|
return <-ch
|
|
}
|
|
|
|
// isJSNotExist reports whether err is the sentinel "ENOENT" from JS.
|
|
func isJSNotExist(err error) bool {
|
|
return err != nil && err.Error() == "ENOENT"
|
|
}
|
|
|
|
func (j jsFileOps) OpenWriter(name string, offset int64, _ os.FileMode) (io.WriteCloser, string, error) {
|
|
if err := j.jsCallVoid("openWriter", name, offset); err != nil {
|
|
return nil, "", err
|
|
}
|
|
return &jsWriteCloser{ops: j, name: name}, name, nil
|
|
}
|
|
|
|
type jsWriteCloser struct {
|
|
ops jsFileOps
|
|
name string
|
|
}
|
|
|
|
func (w *jsWriteCloser) Write(p []byte) (int, error) {
|
|
buf := js.Global().Get("Uint8Array").New(len(p))
|
|
js.CopyBytesToJS(buf, p)
|
|
if err := w.ops.jsCallVoid("write", w.name, buf); err != nil {
|
|
return 0, err
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
func (w *jsWriteCloser) Close() error {
|
|
return w.ops.jsCallVoid("closeWriter", w.name)
|
|
}
|
|
|
|
func (j jsFileOps) Remove(name string) error {
|
|
err := j.jsCallVoid("remove", name)
|
|
if isJSNotExist(err) {
|
|
return &fs.PathError{Op: "remove", Path: name, Err: fs.ErrNotExist}
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (j jsFileOps) Rename(oldPath, newName string) (string, error) {
|
|
val, err := j.jsCallResult("rename", oldPath, newName)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return val.String(), nil
|
|
}
|
|
|
|
func (j jsFileOps) ListFiles() ([]string, error) {
|
|
val, err := j.jsCallResult("listFiles")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
n := val.Length()
|
|
names := make([]string, n)
|
|
for i := 0; i < n; i++ {
|
|
names[i] = val.Index(i).String()
|
|
}
|
|
return names, nil
|
|
}
|
|
|
|
func (j jsFileOps) Stat(name string) (fs.FileInfo, error) {
|
|
val, err := j.jsCallResult("stat", name)
|
|
if isJSNotExist(err) {
|
|
return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrNotExist}
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Use Float to correctly handle files larger than 2 GiB (int is 32-bit on wasm).
|
|
return &jsFileInfo{name: name, size: int64(val.Float())}, nil
|
|
}
|
|
|
|
func (j jsFileOps) OpenReader(name string) (io.ReadCloser, error) {
|
|
val, err := j.jsCallResult("openReader", name)
|
|
if isJSNotExist(err) {
|
|
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// val is a ReadableStream; wrap its reader for streaming delivery to Go.
|
|
reader := val.Call("getReader")
|
|
return &jsStreamReader{reader: reader}, nil
|
|
}
|
|
|
|
// jsFileInfo is a minimal [fs.FileInfo] backed by a name and a size.
|
|
// Only Size() is used by the taildrop manager; the other fields are stubs.
|
|
type jsFileInfo struct {
|
|
name string
|
|
size int64
|
|
}
|
|
|
|
func (i *jsFileInfo) Name() string { return i.name }
|
|
func (i *jsFileInfo) Size() int64 { return i.size }
|
|
func (i *jsFileInfo) Mode() fs.FileMode { return 0o444 }
|
|
func (i *jsFileInfo) ModTime() time.Time { return time.Time{} }
|
|
func (i *jsFileInfo) IsDir() bool { return false }
|
|
func (i *jsFileInfo) Sys() any { return nil }
|