2 Commits

Author SHA1 Message Date
codinget 6e83d5291b feat(tsconnect/wasm): add shutdown() to jsIPN
Expose a shutdown() method on the JS-side IPN object that stops the
LocalBackend, closes the safesocket listener (which unblocks srv.Run),
and signals main() to return so the Go runtime exits cleanly.

This allows the host environment (Node.js process or browser service
worker) to terminate normally once the Tailscale WASM module is no
longer needed, instead of being kept alive indefinitely by open handles,
goroutines, or the Go runtime's blocking main goroutine.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 00:22:56 +00:00
codinget 21d0f11d85 feat(taildrop): stream files via ReadableStream on send and receive
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>
2026-06-11 21:13:04 +00:00
5 changed files with 194 additions and 414 deletions
-301
View File
@@ -1,301 +0,0 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_drive
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sync"
"syscall/js"
"tailscale.com/drive"
"tailscale.com/tailcfg"
"tailscale.com/tsd"
)
// Compile-time check that jsFileSystemForRemote implements drive.FileSystemForRemote.
var _ drive.FileSystemForRemote = (*jsFileSystemForRemote)(nil)
// jsFileSystemForRemote implements drive.FileSystemForRemote by bridging
// incoming WebDAV requests to a JS handler function. Auth and permission
// parsing are handled upstream by handleServeDrive before this is called.
type jsFileSystemForRemote struct {
mu sync.RWMutex
fn js.Value
}
func (fs *jsFileSystemForRemote) setHandler(fn js.Value) {
fs.mu.Lock()
fs.fn = fn
fs.mu.Unlock()
}
// SetFileServerAddr is a no-op: the JS handler owns its own storage.
func (fs *jsFileSystemForRemote) SetFileServerAddr(_ string) {}
// SetShares is a no-op: the JS handler controls which shares it exposes.
func (fs *jsFileSystemForRemote) SetShares(_ []*drive.Share) {}
// Close is a no-op.
func (fs *jsFileSystemForRemote) Close() error { return nil }
// ServeHTTPWithPerms handles a WebDAV request by bridging it to the JS handler.
// It streams the request body to JS via readBodyChunk() and streams the
// response body back via write()/end() callbacks, so no full-body buffering
// occurs regardless of file size.
//
// The call blocks until JS calls end() (or a write error occurs).
func (fs *jsFileSystemForRemote) ServeHTTPWithPerms(
perms drive.Permissions, w http.ResponseWriter, r *http.Request,
) {
fs.mu.RLock()
fn := fs.fn
fs.mu.RUnlock()
if fn.IsUndefined() || fn.IsNull() {
http.NotFound(w, r)
return
}
// readBodyChunk is exposed to JS as req.readBodyChunk().
// Each call returns a Promise<Uint8Array|null>: null signals EOF.
readBodyChunk := js.FuncOf(func(_ js.Value, _ []js.Value) any {
return makePromise(func() (any, error) {
buf := make([]byte, 65536)
n, err := r.Body.Read(buf)
if n > 0 {
arr := js.Global().Get("Uint8Array").New(n)
js.CopyBytesToJS(arr, buf[:n])
return arr, nil
}
if errors.Is(err, io.EOF) {
return js.Null(), nil
}
return nil, err
})
})
// doneCh receives nil when JS calls end(), or a write error if Write fails.
doneCh := make(chan error, 1)
// writeHead sets response headers and status code. Must be called before write().
writeHead := js.FuncOf(func(_ js.Value, args []js.Value) any {
if len(args) < 1 {
return nil
}
status := args[0].Int()
if len(args) > 1 && !args[1].IsUndefined() && !args[1].IsNull() {
for k, vs := range jsHeadersToGo(args[1]) {
for _, v := range vs {
w.Header().Add(k, v)
}
}
}
w.WriteHeader(status)
return nil
})
// write streams a single response body chunk to the client.
write := js.FuncOf(func(_ js.Value, args []js.Value) any {
if len(args) < 1 {
return nil
}
data := args[0]
buf := make([]byte, data.Get("length").Int())
js.CopyBytesToGo(buf, data)
if _, werr := w.Write(buf); werr != nil {
select {
case doneCh <- werr:
default:
}
return nil
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
return nil
})
// end signals that the response is complete.
end := js.FuncOf(func(_ js.Value, _ []js.Value) any {
select {
case doneCh <- nil:
default:
}
return nil
})
defer func() {
readBodyChunk.Release()
writeHead.Release()
write.Release()
end.Release()
}()
jsReq := map[string]any{
"method": r.Method,
"path": r.URL.Path,
"rawQuery": r.URL.RawQuery,
"headers": goHeadersToJS(r.Header),
"readBodyChunk": readBodyChunk,
}
jsRes := map[string]any{
"writeHead": writeHead,
"write": write,
"end": end,
}
fn.Invoke(jsReq, jsRes, drivePermsToJS(perms))
// Block this goroutine until JS calls end() or a write error occurs.
// The Go WASM scheduler yields back to JS while we wait.
<-doneCh
}
// drivePermsToJS converts drive.Permissions to a plain JS-friendly object.
// Each share name maps to a numeric permission: 0=none, 1=read-only, 2=read-write.
// The wildcard share name "*" is included if present.
func drivePermsToJS(p drive.Permissions) map[string]any {
result := make(map[string]any, len(p))
for name, perm := range p {
result[name] = int(perm)
}
return result
}
// goHeadersToJS converts an http.Header to a map[string]any suitable for JS.
// Single-value headers become a string; multi-value headers become a []any.
func goHeadersToJS(h http.Header) map[string]any {
result := make(map[string]any, len(h))
for k, vs := range h {
if len(vs) == 1 {
result[k] = vs[0]
} else {
arr := make([]any, len(vs))
for i, v := range vs {
arr[i] = v
}
result[k] = arr
}
}
return result
}
// jsHeadersToGo parses a JS headers object into an http.Header map.
// Values may be a string or an array of strings.
func jsHeadersToGo(jsHeaders js.Value) http.Header {
h := make(http.Header)
keys := js.Global().Get("Object").Call("keys", jsHeaders)
for i := 0; i < keys.Length(); i++ {
key := keys.Index(i).String()
val := jsHeaders.Get(key)
switch val.Type() {
case js.TypeString:
h.Set(key, val.String())
case js.TypeObject:
if val.InstanceOf(js.Global().Get("Array")) {
for j := 0; j < val.Length(); j++ {
h.Add(key, val.Index(j).String())
}
}
}
}
return h
}
// initDriveForRemote creates the JS-backed FileSystemForRemote and registers
// it with sys. Must be called before NewLocalBackend (SubSystem is set-once).
func initDriveForRemote(sys *tsd.System) *jsFileSystemForRemote {
driveFS := &jsFileSystemForRemote{}
sys.Set(driveFS)
return driveFS
}
// wireDriveJS adds drive-related methods to the IPN JS methods map.
// driveFS must be the value returned by initDriveForRemote.
func wireDriveJS(i *jsIPN, driveFS *jsFileSystemForRemote, m map[string]any) {
m["setDriveHandler"] = js.FuncOf(func(_ js.Value, args []js.Value) any {
if len(args) < 1 {
return nil
}
driveFS.setHandler(args[0])
return nil
})
m["listDrivePeers"] = js.FuncOf(func(_ js.Value, _ []js.Value) any {
return i.listDrivePeers()
})
}
type jsDrivePeer struct {
Name string `json:"name"`
PeerAPIURL string `json:"peerAPIURL"`
StableNodeID string `json:"stableNodeID"`
Online *bool `json:"online,omitempty"`
}
// listDrivePeers returns a JSON array of peers that carry
// PeerCapabilityTaildriveSharer. Returns an empty array if the local node
// does not have drive:access in its ACL (DriveAccessEnabled). This mirrors
// the filtering in LocalBackend.driveRemotesFromPeers.
func (i *jsIPN) listDrivePeers() js.Value {
return makePromise(func() (any, error) {
if !i.lb.DriveAccessEnabled() {
return "[]", nil
}
nm := i.lb.NetMap()
if nm == nil {
return nil, errors.New("listDrivePeers: no network map available")
}
var selfHave4, selfHave6 bool
for _, a := range nm.GetAddresses().All() {
if !a.IsSingleIP() {
continue
}
if a.Addr().Is4() {
selfHave4 = true
} else if a.Addr().Is6() {
selfHave6 = true
}
}
peers := make([]jsDrivePeer, 0)
for _, p := range nm.Peers {
// Check PeerCapabilityTaildriveSharer via the live PeerCaps map
// (derived from ACL rules), mirroring driveRemotesFromPeers.
hasCap := false
for _, a := range p.Addresses().All() {
if a.IsSingleIP() && i.lb.PeerCaps(a.Addr()).HasCapability(tailcfg.PeerCapabilityTaildriveSharer) {
hasCap = true
break
}
}
if !hasCap {
continue
}
peerURL := buildPeerAPIURL(p, selfHave4, selfHave6)
online := p.Online().Clone()
peers = append(peers, jsDrivePeer{
Name: p.DisplayName(false),
PeerAPIURL: peerURL,
StableNodeID: string(p.StableID()),
Online: online,
})
}
b, err := json.Marshal(peers)
if err != nil {
return nil, fmt.Errorf("listDrivePeers: marshal: %w", err)
}
return string(b), nil
})
}
-27
View File
@@ -1,27 +0,0 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build ts_omit_drive
package main
import (
"syscall/js"
"tailscale.com/tsd"
)
type jsFileSystemForRemote struct{}
// initDriveForRemote is a no-op when the drive feature is omitted.
func initDriveForRemote(_ *tsd.System) *jsFileSystemForRemote { return nil }
// wireDriveJS is a no-op when the drive feature is omitted.
func wireDriveJS(_ *jsIPN, _ *jsFileSystemForRemote, _ map[string]any) {}
// listDrivePeers returns an empty list when the drive feature is omitted.
func (i *jsIPN) listDrivePeers() js.Value {
return makePromise(func() (any, error) {
return "[]", nil
})
}
-41
View File
@@ -1,41 +0,0 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"fmt"
"net/netip"
"tailscale.com/tailcfg"
)
// buildPeerAPIURL returns the HTTP base URL for a peer's peerAPI server,
// selecting IPv4 when available and falling back to IPv6. Returns an empty
// string if the peer advertises no reachable peerAPI port.
func buildPeerAPIURL(p tailcfg.NodeView, selfHave4, selfHave6 bool) string {
var pp4, pp6 uint16
for _, s := range p.Hostinfo().Services().All() {
switch s.Proto {
case tailcfg.PeerAPI4:
pp4 = s.Port
case tailcfg.PeerAPI6:
pp6 = s.Port
}
}
if selfHave4 && pp4 != 0 {
for _, a := range p.Addresses().All() {
if a.IsSingleIP() && a.Addr().Is4() {
return fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), pp4))
}
}
}
if selfHave6 && pp6 != 0 {
for _, a := range p.Addresses().All() {
if a.IsSingleIP() && a.Addr().Is6() {
return fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), pp6))
}
}
}
return ""
}
+127 -22
View File
@@ -9,7 +9,6 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
@@ -81,9 +80,11 @@ func (i *jsIPN) listFileTargets() js.Value {
})
}
// sendFile sends data as filename to the peer identified by stableNodeID,
// sendFile sends stream as filename to the peer identified by stableNodeID,
// reporting progress via notifyOutgoingFiles callbacks roughly once per second.
func (i *jsIPN) sendFile(stableNodeID, filename string, data js.Value) js.Value {
// 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 {
@@ -107,14 +108,15 @@ func (i *jsIPN) sendFile(stableNodeID, filename string, data js.Value) js.Value
if err != nil {
return nil, fmt.Errorf("bogus peer URL: %w", err)
}
b := make([]byte, data.Get("byteLength").Int())
js.CopyBytesToGo(b, data)
reader := stream.Call("getReader")
body := &jsStreamReader{reader: reader}
outgoing := &ipn.OutgoingFile{
ID: rands.HexString(30),
PeerID: tailcfg.StableNodeID(stableNodeID),
Name: filename,
DeclaredSize: int64(len(b)),
DeclaredSize: int64(declaredSize),
Started: time.Now(),
}
updates := map[string]*ipn.OutgoingFile{outgoing.ID: outgoing}
@@ -127,17 +129,17 @@ func (i *jsIPN) sendFile(stableNodeID, filename string, data js.Value) js.Value
ext.UpdateOutgoingFiles(updates)
}()
body := progresstracking.NewReader(bytes.NewReader(b), time.Second, func(n int, _ error) {
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), body)
req, err := http.NewRequest("PUT", dstURL.String()+"/v0/put/"+url.PathEscape(filename), progressBody)
if err != nil {
sendErr = err
return nil, err
}
req.ContentLength = int64(len(b))
req.ContentLength = int64(declaredSize)
client := &http.Client{Transport: i.lb.Dialer().PeerAPITransport()}
resp, err := client.Do(req)
if err != nil {
@@ -147,7 +149,13 @@ func (i *jsIPN) sendFile(stableNodeID, filename string, data js.Value) js.Value
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
respBody, _ := io.ReadAll(resp.Body)
sendErr = fmt.Errorf("send file: %s: %s", resp.Status, bytes.TrimSpace(respBody))
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
@@ -182,7 +190,8 @@ func (i *jsIPN) waitingFiles() js.Value {
})
}
// openWaitingFile returns the contents of a received file as a Uint8Array.
// 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()
@@ -193,14 +202,7 @@ func (i *jsIPN) openWaitingFile(name string) js.Value {
if err != nil {
return nil, err
}
defer rc.Close()
data, err := io.ReadAll(rc)
if err != nil {
return nil, err
}
buf := js.Global().Get("Uint8Array").New(len(data))
js.CopyBytesToJS(buf, data)
return buf, nil
return jsReadableStream(rc), nil
})
}
@@ -234,6 +236,109 @@ func wireTaildropFileOps(lb *ipnlocal.LocalBackend, jsObj js.Value) {
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:
//
@@ -385,9 +490,9 @@ func (j jsFileOps) OpenReader(name string) (io.ReadCloser, error) {
if err != nil {
return nil, err
}
b := make([]byte, val.Get("byteLength").Int())
js.CopyBytesToGo(b, val)
return io.NopCloser(bytes.NewReader(b)), nil
// 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.
+67 -23
View File
@@ -66,19 +66,20 @@ import (
var ControlURL = ipn.DefaultControlURL
func main() {
shutdownCh := make(chan struct{})
js.Global().Set("newIPN", js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 1 {
log.Fatal("Usage: newIPN(config)")
return nil
}
return newIPN(args[0])
return newIPN(args[0], shutdownCh)
}))
// Keep Go runtime alive, otherwise it will be shut down before newIPN gets
// called.
<-make(chan bool)
// Block until shutdown() is called on the IPN, then let main return so the
// Go runtime (and all its goroutines) can be collected by the JS engine.
<-shutdownCh
}
func newIPN(jsConfig js.Value) map[string]any {
func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
netns.SetEnabled(false)
var store ipn.StateStore
@@ -158,10 +159,6 @@ func newIPN(jsConfig js.Value) map[string]any {
sys.Tun.Get().Start()
logid := lpc.PublicID
// initDriveForRemote must be called before NewLocalBackend (SubSystem is set-once).
driveFS := initDriveForRemote(sys)
srv := ipnserver.New(logf, logid, sys.Bus.Get(), sys.NetMon.Get())
lb, err := ipnlocal.NewLocalBackend(logf, logid, sys, controlclient.LoginEphemeral)
if err != nil {
@@ -183,10 +180,11 @@ func newIPN(jsConfig js.Value) map[string]any {
hostname: hostname,
logID: logid,
funnelPorts: make(map[uint16]*funnelListenerEntry),
shutdownCh: shutdownCh,
}
lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP)
m := map[string]any{
return map[string]any{
"run": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 1 {
log.Fatal(`Usage: run({
@@ -285,11 +283,11 @@ func newIPN(jsConfig js.Value) map[string]any {
return jsIPN.listFileTargets()
}),
"sendFile": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 3 {
log.Printf("Usage: sendFile(stableNodeID, filename, data)")
if len(args) != 4 {
log.Printf("Usage: sendFile(stableNodeID, filename, stream, declaredSize)")
return nil
}
return jsIPN.sendFile(args[0].String(), args[1].String(), args[2])
return jsIPN.sendFile(args[0].String(), args[1].String(), args[2], args[3].Int())
}),
"waitingFiles": js.FuncOf(func(this js.Value, args []js.Value) any {
return jsIPN.waitingFiles()
@@ -365,6 +363,9 @@ func newIPN(jsConfig js.Value) map[string]any {
"suggestExitNode": js.FuncOf(func(this js.Value, args []js.Value) any {
return jsIPN.suggestExitNode()
}),
"shutdown": js.FuncOf(func(this js.Value, args []js.Value) any {
return jsIPN.shutdown()
}),
"localAPI": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) < 2 {
log.Printf("Usage: localAPI(method, path[, body])")
@@ -377,8 +378,6 @@ func newIPN(jsConfig js.Value) map[string]any {
return jsIPN.localAPI(args[0].String(), args[1].String(), body)
}),
}
wireDriveJS(jsIPN, driveFS, m)
return m
}
type jsIPN struct {
@@ -393,6 +392,12 @@ type jsIPN struct {
funnelMu sync.Mutex
funnelPorts map[uint16]*funnelListenerEntry
// ln is the safesocket listener created by run(); stored here so shutdown
// can close it and unblock srv.Run.
ln net.Listener
shutdownCh chan struct{} // closed by shutdown() to unblock main()
shutdownOnce sync.Once
}
// funnelListenerEntry is the per-port state for routing Funnel connections to a listenTLS listener.
@@ -488,7 +493,32 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
}
// Peer peerAPI URL from the peer's advertised Services.
peerURL := buildPeerAPIURL(p, selfHave4, selfHave6)
peerURL := ""
var pp4, pp6 uint16
for _, s := range p.Hostinfo().Services().All() {
switch s.Proto {
case tailcfg.PeerAPI4:
pp4 = s.Port
case tailcfg.PeerAPI6:
pp6 = s.Port
}
}
if selfHave4 && pp4 != 0 {
for _, a := range p.Addresses().All() {
if a.IsSingleIP() && a.Addr().Is4() {
peerURL = fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), pp4))
break
}
}
}
if peerURL == "" && selfHave6 && pp6 != 0 {
for _, a := range p.Addresses().All() {
if a.IsSingleIP() && a.Addr().Is6() {
peerURL = fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), pp6))
break
}
}
}
return jsNetMapPeerNode{
jsNetMapNode: jsNetMapNode{
@@ -575,14 +605,17 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
}
}()
go func() {
ln, err := safesocket.Listen("")
if err != nil {
log.Fatalf("safesocket.Listen: %v", err)
}
ln, err := safesocket.Listen("")
if err != nil {
log.Fatalf("safesocket.Listen: %v", err)
}
i.ln = ln
err = i.srv.Run(context.Background(), ln)
log.Fatalf("ipnserver.Run exited: %v", err)
go func() {
err := i.srv.Run(context.Background(), ln)
if err != nil && !errors.Is(err, net.ErrClosed) {
log.Fatalf("ipnserver.Run exited: %v", err)
}
}()
}
@@ -601,6 +634,17 @@ func (i *jsIPN) logout() {
}()
}
func (i *jsIPN) shutdown() js.Value {
return makePromise(func() (any, error) {
i.shutdownOnce.Do(func() {
i.lb.Shutdown()
i.ln.Close()
close(i.shutdownCh)
})
return nil, nil
})
}
func (i *jsIPN) ssh(host, username string, termConfig js.Value) map[string]any {
jsSSHSession := &jsSSHSession{
jsIPN: i,