tsconnect: harden JS bridge callbacks
Contain synchronous and asynchronous JS failures, make Drive cancellation fence response callbacks, and keep netmap snapshots current across peer changes and watcher restarts. Co-Authored-By: gpt-5.6-sol <noreply@openai.com>
This commit is contained in:
@@ -0,0 +1,235 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
//go:build js && wasm
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"syscall/js"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"tailscale.com/ipn"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMakePromiseRejectsJSError(t *testing.T) {
|
||||||
|
errValue := js.Global().Get("Error").New("boom")
|
||||||
|
promise := makePromise(func() (any, error) {
|
||||||
|
panic(js.Error{Value: errValue})
|
||||||
|
})
|
||||||
|
rejection := awaitPromise(t, promise)
|
||||||
|
if rejection.Type() != js.TypeObject || rejection.Get("message").String() != "boom" {
|
||||||
|
t.Fatalf("rejection = %v, want Error(boom)", rejection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDriveHandlerThrowReturns500(t *testing.T) {
|
||||||
|
fn := evalJS(t, `(function() { throw new Error("boom") })`)
|
||||||
|
fs := new(jsFileSystemForRemote)
|
||||||
|
fs.setHandler(fn)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
fs.ServeHTTPWithPerms(nil, recorder, httptest.NewRequest("GET", "/", nil))
|
||||||
|
if recorder.Code != 500 {
|
||||||
|
t.Fatalf("status = %d, want 500", recorder.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDriveCancellationDisablesCallbacks(t *testing.T) {
|
||||||
|
started := make(chan struct{})
|
||||||
|
handler := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
|
close(started)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
defer handler.Release()
|
||||||
|
fs := new(jsFileSystemForRemote)
|
||||||
|
fs.setHandler(handler.Value)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
fs.ServeHTTPWithPerms(nil, recorder, httptest.NewRequest("GET", "/", nil).WithContext(ctx))
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-started:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("drive handler did not start")
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("drive handler did not stop after cancellation")
|
||||||
|
}
|
||||||
|
|
||||||
|
writeStarted := make(chan struct{})
|
||||||
|
allowWrite := make(chan struct{})
|
||||||
|
response := &driveResponse{
|
||||||
|
w: recorder,
|
||||||
|
doneCh: make(chan error, 1),
|
||||||
|
live: true,
|
||||||
|
testBeforeWriteCheck: func() {
|
||||||
|
close(writeStarted)
|
||||||
|
<-allowWrite
|
||||||
|
},
|
||||||
|
}
|
||||||
|
chunk := js.Global().Get("Uint8Array").New(1)
|
||||||
|
js.CopyBytesToJS(chunk, []byte("x"))
|
||||||
|
writeReturned := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
response.write([]js.Value{chunk})
|
||||||
|
close(writeReturned)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-writeStarted:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("response write did not start")
|
||||||
|
}
|
||||||
|
response.finish(context.Canceled, context.Canceled)
|
||||||
|
close(allowWrite)
|
||||||
|
select {
|
||||||
|
case <-writeReturned:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("response callback did not finish")
|
||||||
|
}
|
||||||
|
if recorder.Body.Len() != 0 {
|
||||||
|
t.Fatalf("late write changed response body to %q", recorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSStreamReaderErrorsAreSticky(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
reader string
|
||||||
|
}{
|
||||||
|
{"rejected", `({read() { return Promise.reject("boom") }, cancel() { return Promise.resolve() }})`},
|
||||||
|
{"throwing", `({read() { throw new Error("boom") }, cancel() { return Promise.resolve() }})`},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
r := &jsStreamReader{reader: evalJS(t, test.reader)}
|
||||||
|
_, first := r.Read(make([]byte, 1))
|
||||||
|
_, second := r.Read(make([]byte, 1))
|
||||||
|
if first == nil || second == nil || first.Error() != second.Error() || !strings.Contains(first.Error(), "boom") {
|
||||||
|
t.Fatalf("read errors = %v, %v; want matching boom errors", first, second)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSStreamReaderCloseErrors(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
reader string
|
||||||
|
}{
|
||||||
|
{"rejected", `({cancel() { return Promise.reject("boom") }})`},
|
||||||
|
{"throwing", `({cancel() { throw new Error("boom") }})`},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
r := &jsStreamReader{reader: evalJS(t, test.reader)}
|
||||||
|
if err := r.Close(); err == nil || !strings.Contains(err.Error(), "boom") {
|
||||||
|
t.Fatalf("Close error = %v, want boom", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSStreamReaderIgnoresDuplicateSettlement(t *testing.T) {
|
||||||
|
reader := evalJS(t, `({
|
||||||
|
read() {
|
||||||
|
return {then(resolve, reject) { resolve({done: true}); reject("late") }}
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
return {then(resolve, reject) { resolve(); reject("late") }}
|
||||||
|
},
|
||||||
|
})`)
|
||||||
|
r := &jsStreamReader{reader: reader}
|
||||||
|
if _, err := r.Read(make([]byte, 1)); !errors.Is(err, io.EOF) {
|
||||||
|
t.Fatalf("Read error = %v, want EOF", err)
|
||||||
|
}
|
||||||
|
if err := r.Close(); err != nil {
|
||||||
|
t.Fatalf("Close error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotifyRefreshesNetMap(t *testing.T) {
|
||||||
|
online := true
|
||||||
|
lastSeen := time.Now()
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
n ipn.Notify
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"self", ipn.Notify{SelfChange: new(tailcfg.Node)}, true},
|
||||||
|
{"peer changed", ipn.Notify{PeersChanged: []*tailcfg.Node{{}}}, true},
|
||||||
|
{"peer removed", ipn.Notify{PeersRemoved: []tailcfg.NodeID{1}}, true},
|
||||||
|
{"online patch", ipn.Notify{PeerChangedPatch: []*tailcfg.PeerChange{{Online: &online}}}, true},
|
||||||
|
{"last seen patch", ipn.Notify{PeerChangedPatch: []*tailcfg.PeerChange{{LastSeen: &lastSeen}}}, false},
|
||||||
|
{"state only", ipn.Notify{State: new(ipn.State)}, false},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if got := notifyRefreshesNetMap(test.n); got != test.want {
|
||||||
|
t.Fatalf("notifyRefreshesNetMap() = %v, want %v", got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotifyWatchMask(t *testing.T) {
|
||||||
|
want := ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyPeerChanges | ipn.NotifyPeerPatches
|
||||||
|
if jsIPNNotifyWatchMask != want {
|
||||||
|
t.Fatalf("jsIPNNotifyWatchMask = %v, want %v", jsIPNNotifyWatchMask, want)
|
||||||
|
}
|
||||||
|
if jsIPNNotifyWatchMask&ipn.NotifyInProcessNoDisconnect != 0 {
|
||||||
|
t.Fatal("jsIPNNotifyWatchMask must allow a lagging watcher to disconnect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func evalJS(t *testing.T, source string) js.Value {
|
||||||
|
t.Helper()
|
||||||
|
value, err := callJSFunction(js.Global().Get("eval"), source)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func awaitPromise(t *testing.T, promise js.Value) js.Value {
|
||||||
|
t.Helper()
|
||||||
|
type result struct {
|
||||||
|
value js.Value
|
||||||
|
rejected bool
|
||||||
|
}
|
||||||
|
ch := make(chan result, 1)
|
||||||
|
resolve := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
|
ch <- result{value: args[0]}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
reject := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
|
ch <- result{value: args[0], rejected: true}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
defer resolve.Release()
|
||||||
|
defer reject.Release()
|
||||||
|
if _, err := callJSMethod(promise, "then", resolve, reject); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case result := <-ch:
|
||||||
|
if !result.rejected {
|
||||||
|
t.Fatal(errors.New("promise resolved; want rejection"))
|
||||||
|
}
|
||||||
|
return result.value
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("promise did not settle")
|
||||||
|
return js.Undefined()
|
||||||
|
}
|
||||||
|
}
|
||||||
+114
-38
@@ -30,6 +30,73 @@ type jsFileSystemForRemote struct {
|
|||||||
fn js.Value
|
fn js.Value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type driveResponse struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
w http.ResponseWriter
|
||||||
|
doneCh chan error
|
||||||
|
live bool
|
||||||
|
responseStarted bool
|
||||||
|
testBeforeWriteCheck func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *driveResponse) isLive() bool {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.live
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *driveResponse) writeHead(args []js.Value) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if !r.live || len(args) < 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
r.w.Header().Add(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.w.WriteHeader(status)
|
||||||
|
r.responseStarted = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *driveResponse) write(args []js.Value) {
|
||||||
|
if r.testBeforeWriteCheck != nil {
|
||||||
|
r.testBeforeWriteCheck()
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if !r.live || len(args) < 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := args[0]
|
||||||
|
buf := make([]byte, data.Get("length").Int())
|
||||||
|
js.CopyBytesToGo(buf, data)
|
||||||
|
r.responseStarted = true
|
||||||
|
if _, err := r.w.Write(buf); err != nil {
|
||||||
|
select {
|
||||||
|
case r.doneCh <- err:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if f, ok := r.w.(http.Flusher); ok {
|
||||||
|
f.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *driveResponse) finish(resultErr, contextErr error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if resultErr != nil && contextErr == nil && !r.responseStarted {
|
||||||
|
http.Error(r.w, "drive handler failed", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
r.live = false
|
||||||
|
}
|
||||||
|
|
||||||
func (fs *jsFileSystemForRemote) setHandler(fn js.Value) {
|
func (fs *jsFileSystemForRemote) setHandler(fn js.Value) {
|
||||||
fs.mu.Lock()
|
fs.mu.Lock()
|
||||||
fs.fn = fn
|
fs.fn = fn
|
||||||
@@ -50,7 +117,8 @@ func (fs *jsFileSystemForRemote) Close() error { return nil }
|
|||||||
// response body back via write()/end() callbacks, so no full-body buffering
|
// response body back via write()/end() callbacks, so no full-body buffering
|
||||||
// occurs regardless of file size.
|
// occurs regardless of file size.
|
||||||
//
|
//
|
||||||
// The call blocks until JS calls end() (or a write error occurs).
|
// The call blocks until JS calls end(), a write or handler error occurs, or
|
||||||
|
// the request context is cancelled.
|
||||||
func (fs *jsFileSystemForRemote) ServeHTTPWithPerms(
|
func (fs *jsFileSystemForRemote) ServeHTTPWithPerms(
|
||||||
perms drive.Permissions, w http.ResponseWriter, r *http.Request,
|
perms drive.Permissions, w http.ResponseWriter, r *http.Request,
|
||||||
) {
|
) {
|
||||||
@@ -58,14 +126,21 @@ func (fs *jsFileSystemForRemote) ServeHTTPWithPerms(
|
|||||||
fn := fs.fn
|
fn := fs.fn
|
||||||
fs.mu.RUnlock()
|
fs.mu.RUnlock()
|
||||||
|
|
||||||
if fn.IsUndefined() || fn.IsNull() {
|
if fn.Type() != js.TypeFunction {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
response := &driveResponse{w: w, doneCh: make(chan error, 1), live: true}
|
||||||
|
|
||||||
// readBodyChunk is exposed to JS as req.readBodyChunk().
|
// readBodyChunk is exposed to JS as req.readBodyChunk().
|
||||||
// Each call returns a Promise<Uint8Array|null>: null signals EOF.
|
// Each call returns a Promise<Uint8Array|null>: null signals EOF.
|
||||||
readBodyChunk := js.FuncOf(func(_ js.Value, _ []js.Value) any {
|
readBodyChunk := js.FuncOf(func(_ js.Value, _ []js.Value) any {
|
||||||
|
if !response.isLive() {
|
||||||
|
return makePromise(func() (any, error) {
|
||||||
|
return nil, errors.New("drive request is closed")
|
||||||
|
})
|
||||||
|
}
|
||||||
return makePromise(func() (any, error) {
|
return makePromise(func() (any, error) {
|
||||||
buf := make([]byte, 65536)
|
buf := make([]byte, 65536)
|
||||||
n, err := r.Body.Read(buf)
|
n, err := r.Body.Read(buf)
|
||||||
@@ -81,51 +156,33 @@ func (fs *jsFileSystemForRemote) ServeHTTPWithPerms(
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 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 sets response headers and status code. Must be called before write().
|
||||||
writeHead := js.FuncOf(func(_ js.Value, args []js.Value) any {
|
writeHead := js.FuncOf(func(_ js.Value, args []js.Value) any {
|
||||||
if len(args) < 1 {
|
response.writeHead(args)
|
||||||
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
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// write streams a single response body chunk to the client.
|
// write streams a single response body chunk to the client.
|
||||||
write := js.FuncOf(func(_ js.Value, args []js.Value) any {
|
write := js.FuncOf(func(_ js.Value, args []js.Value) any {
|
||||||
if len(args) < 1 {
|
response.write(args)
|
||||||
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
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// end signals that the response is complete.
|
// end signals that the response is complete.
|
||||||
end := js.FuncOf(func(_ js.Value, _ []js.Value) any {
|
end := js.FuncOf(func(_ js.Value, _ []js.Value) any {
|
||||||
select {
|
select {
|
||||||
case doneCh <- nil:
|
case response.doneCh <- nil:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
rejected := js.FuncOf(func(_ js.Value, args []js.Value) any {
|
||||||
|
err := errors.New("JavaScript drive handler rejected")
|
||||||
|
if len(args) > 0 && args[0].Type() == js.TypeString {
|
||||||
|
err = fmt.Errorf("JavaScript drive handler rejected: %s", args[0].String())
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case response.doneCh <- err:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -136,6 +193,7 @@ func (fs *jsFileSystemForRemote) ServeHTTPWithPerms(
|
|||||||
writeHead.Release()
|
writeHead.Release()
|
||||||
write.Release()
|
write.Release()
|
||||||
end.Release()
|
end.Release()
|
||||||
|
rejected.Release()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
jsReq := map[string]any{
|
jsReq := map[string]any{
|
||||||
@@ -151,11 +209,29 @@ func (fs *jsFileSystemForRemote) ServeHTTPWithPerms(
|
|||||||
"end": end,
|
"end": end,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn.Invoke(jsReq, jsRes, drivePermsToJS(perms))
|
result, handlerErr := callJSFunction(fn, jsReq, jsRes, drivePermsToJS(perms))
|
||||||
|
if handlerErr == nil {
|
||||||
|
if hasThen, err := hasJSFunctionProperty(result, "then"); err != nil {
|
||||||
|
handlerErr = err
|
||||||
|
} else if hasThen {
|
||||||
|
_, handlerErr = callJSMethod(result, "then", js.Undefined(), rejected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if handlerErr != nil {
|
||||||
|
select {
|
||||||
|
case response.doneCh <- handlerErr:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Block this goroutine until JS calls end() or a write error occurs.
|
var resultErr error
|
||||||
// The Go WASM scheduler yields back to JS while we wait.
|
select {
|
||||||
<-doneCh
|
case resultErr = <-response.doneCh:
|
||||||
|
case <-r.Context().Done():
|
||||||
|
resultErr = r.Context().Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
response.finish(resultErr, r.Context().Err())
|
||||||
}
|
}
|
||||||
|
|
||||||
// drivePermsToJS converts drive.Permissions to a plain JS-friendly object.
|
// drivePermsToJS converts drive.Permissions to a plain JS-friendly object.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
"syscall/js"
|
"syscall/js"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -109,7 +110,10 @@ func (i *jsIPN) sendFile(stableNodeID, filename string, stream js.Value, declare
|
|||||||
return nil, fmt.Errorf("bogus peer URL: %w", err)
|
return nil, fmt.Errorf("bogus peer URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
reader := stream.Call("getReader")
|
reader, err := callJSMethod(stream, "getReader")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
body := &jsStreamReader{reader: reader}
|
body := &jsStreamReader{reader: reader}
|
||||||
|
|
||||||
outgoing := ipn.OutgoingFile{
|
outgoing := ipn.OutgoingFile{
|
||||||
@@ -242,40 +246,97 @@ func wireTaildropFileOps(lb *ipnlocal.LocalBackend, jsObj js.Value) {
|
|||||||
// ReadableStreamDefaultReader. Each Read call awaits one reader.read() Promise,
|
// ReadableStreamDefaultReader. Each Read call awaits one reader.read() Promise,
|
||||||
// using the channel+FuncOf pattern so Go blocks until JS delivers the chunk.
|
// using the channel+FuncOf pattern so Go blocks until JS delivers the chunk.
|
||||||
type jsStreamReader struct {
|
type jsStreamReader struct {
|
||||||
|
mu sync.Mutex
|
||||||
reader js.Value
|
reader js.Value
|
||||||
buf []byte
|
buf []byte
|
||||||
done bool
|
done bool
|
||||||
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *jsStreamReader) Read(p []byte) (int, error) {
|
func (r *jsStreamReader) Read(p []byte) (int, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
if r.err != nil {
|
||||||
|
err := r.err
|
||||||
|
r.mu.Unlock()
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
if r.done {
|
if r.done {
|
||||||
|
r.mu.Unlock()
|
||||||
return 0, io.EOF
|
return 0, io.EOF
|
||||||
}
|
}
|
||||||
if len(r.buf) > 0 {
|
if len(r.buf) > 0 {
|
||||||
n := copy(p, r.buf)
|
n := copy(p, r.buf)
|
||||||
r.buf = r.buf[n:]
|
r.buf = r.buf[n:]
|
||||||
|
r.mu.Unlock()
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
type chunkResult struct {
|
type chunkResult struct {
|
||||||
data []byte
|
data []byte
|
||||||
done bool
|
done bool
|
||||||
|
err error
|
||||||
}
|
}
|
||||||
ch := make(chan chunkResult, 1)
|
ch := make(chan chunkResult, 1)
|
||||||
|
settle := func(result chunkResult) {
|
||||||
|
select {
|
||||||
|
case ch <- result:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
thenFn := js.FuncOf(func(this js.Value, args []js.Value) any {
|
thenFn := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
settle(chunkResult{err: recoveredJSError(recovered)})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if len(args) == 0 || args[0].Type() != js.TypeObject {
|
||||||
|
settle(chunkResult{err: errors.New("JavaScript stream read returned an invalid result")})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
result := args[0]
|
result := args[0]
|
||||||
if result.Get("done").Bool() {
|
done := result.Get("done")
|
||||||
ch <- chunkResult{done: true}
|
if done.Type() != js.TypeBoolean {
|
||||||
|
settle(chunkResult{err: errors.New("JavaScript stream read result has no boolean done property")})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if done.Bool() {
|
||||||
|
settle(chunkResult{done: true})
|
||||||
} else {
|
} else {
|
||||||
value := result.Get("value")
|
value := result.Get("value")
|
||||||
|
uint8Array := js.Global().Get("Uint8Array")
|
||||||
|
if value.Type() != js.TypeObject || uint8Array.Type() != js.TypeFunction || !value.InstanceOf(uint8Array) {
|
||||||
|
settle(chunkResult{err: errors.New("JavaScript stream read result is not a Uint8Array")})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
b := make([]byte, value.Get("byteLength").Int())
|
b := make([]byte, value.Get("byteLength").Int())
|
||||||
js.CopyBytesToGo(b, value)
|
js.CopyBytesToGo(b, value)
|
||||||
ch <- chunkResult{data: b}
|
settle(chunkResult{data: b})
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
rejectFn := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
|
err := errors.New("JavaScript stream read rejected")
|
||||||
|
if len(args) > 0 && args[0].Type() == js.TypeString {
|
||||||
|
err = fmt.Errorf("JavaScript stream read rejected: %s", args[0].String())
|
||||||
|
}
|
||||||
|
settle(chunkResult{err: err})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
defer thenFn.Release()
|
defer thenFn.Release()
|
||||||
r.reader.Call("read").Call("then", thenFn)
|
defer rejectFn.Release()
|
||||||
|
promise, err := callJSMethod(r.reader, "read")
|
||||||
|
if err != nil {
|
||||||
|
return 0, r.setError(err)
|
||||||
|
}
|
||||||
|
if _, err := callJSMethod(promise, "then", thenFn, rejectFn); err != nil {
|
||||||
|
return 0, r.setError(err)
|
||||||
|
}
|
||||||
result := <-ch
|
result := <-ch
|
||||||
|
if result.err != nil {
|
||||||
|
return 0, r.setError(result.err)
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
if result.done {
|
if result.done {
|
||||||
r.done = true
|
r.done = true
|
||||||
return 0, io.EOF
|
return 0, io.EOF
|
||||||
@@ -286,8 +347,61 @@ func (r *jsStreamReader) Read(p []byte) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *jsStreamReader) Close() error {
|
func (r *jsStreamReader) Close() error {
|
||||||
r.reader.Call("cancel")
|
r.mu.Lock()
|
||||||
|
priorErr := r.err
|
||||||
|
r.mu.Unlock()
|
||||||
|
promise, err := callJSMethod(r.reader, "cancel")
|
||||||
|
if err != nil {
|
||||||
|
return r.setError(err)
|
||||||
|
}
|
||||||
|
if hasThen, err := hasJSFunctionProperty(promise, "then"); err != nil {
|
||||||
|
return r.setError(err)
|
||||||
|
} else if !hasThen {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.done = true
|
||||||
|
r.mu.Unlock()
|
||||||
|
return priorErr
|
||||||
|
}
|
||||||
|
resultCh := make(chan error, 1)
|
||||||
|
settle := func(err error) {
|
||||||
|
select {
|
||||||
|
case resultCh <- err:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolveFn := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
|
settle(nil)
|
||||||
return nil
|
return nil
|
||||||
|
})
|
||||||
|
rejectFn := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
|
err := errors.New("JavaScript stream cancel rejected")
|
||||||
|
if len(args) > 0 && args[0].Type() == js.TypeString {
|
||||||
|
err = fmt.Errorf("JavaScript stream cancel rejected: %s", args[0].String())
|
||||||
|
}
|
||||||
|
settle(err)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
defer resolveFn.Release()
|
||||||
|
defer rejectFn.Release()
|
||||||
|
if _, err := callJSMethod(promise, "then", resolveFn, rejectFn); err != nil {
|
||||||
|
return r.setError(err)
|
||||||
|
}
|
||||||
|
if err := <-resultCh; err != nil {
|
||||||
|
return r.setError(err)
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
r.done = true
|
||||||
|
r.mu.Unlock()
|
||||||
|
return priorErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *jsStreamReader) setError(err error) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.err == nil {
|
||||||
|
r.err = err
|
||||||
|
}
|
||||||
|
return r.err
|
||||||
}
|
}
|
||||||
|
|
||||||
// jsReadableStream wraps rc in a pull-based JS ReadableStream. Each pull call
|
// jsReadableStream wraps rc in a pull-based JS ReadableStream. Each pull call
|
||||||
|
|||||||
+178
-33
@@ -432,6 +432,9 @@ type jsIPN struct {
|
|||||||
|
|
||||||
terminate func() // unblocks main() so the Go runtime can exit
|
terminate func() // unblocks main() so the Go runtime can exit
|
||||||
shutdownOnce sync.Once
|
shutdownOnce sync.Once
|
||||||
|
notifyMu sync.Mutex
|
||||||
|
notifyCancel context.CancelFunc
|
||||||
|
notifyDone <-chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// funnelListenerEntry is the per-port state for routing Funnel connections to a listenTLS listener.
|
// funnelListenerEntry is the per-port state for routing Funnel connections to a listenTLS listener.
|
||||||
@@ -457,34 +460,62 @@ var jsMachineStatus = map[tailcfg.MachineStatus]string{
|
|||||||
tailcfg.MachineInvalid: "MachineInvalid",
|
tailcfg.MachineInvalid: "MachineInvalid",
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *jsIPN) run(jsCallbacks js.Value) {
|
const jsIPNNotifyWatchMask = ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyPeerChanges | ipn.NotifyPeerPatches
|
||||||
notifyState := func(state ipn.State) {
|
|
||||||
jsCallbacks.Call("notifyState", jsIPNState[state])
|
|
||||||
}
|
|
||||||
notifyState(ipn.NoState)
|
|
||||||
|
|
||||||
i.lb.SetNotifyCallback(func(n ipn.Notify) {
|
func notifyRefreshesNetMap(n ipn.Notify) bool {
|
||||||
// Panics in the notify callback are likely due to be due to bugs in
|
if n.SelfChange != nil || len(n.PeersChanged) > 0 || len(n.PeersRemoved) > 0 {
|
||||||
// this bridging module (as opposed to actual bugs in Tailscale) and
|
return true
|
||||||
// thus may be recoverable. Let the UI know, and allow the user to
|
|
||||||
// choose if they want to reload the page.
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
fmt.Println("Panic recovered:", r)
|
|
||||||
jsCallbacks.Call("notifyPanicRecover", fmt.Sprint(r))
|
|
||||||
}
|
}
|
||||||
}()
|
for _, patch := range n.PeerChangedPatch {
|
||||||
log.Printf("NOTIFY: %+v", n)
|
if patch.Online != nil {
|
||||||
if n.State != nil {
|
return true
|
||||||
notifyState(*n.State)
|
|
||||||
}
|
}
|
||||||
if n.SelfChange != nil {
|
}
|
||||||
// Self changed: rebuild the JS-side NetMap snapshot. Peers
|
return false
|
||||||
// don't ride on the bus anymore, so fetch them on demand
|
}
|
||||||
// from LocalBackend.
|
|
||||||
|
func (i *jsIPN) watchNotifications(ctx context.Context, registered, done chan struct{}, notify func(ipn.Notify), refresh func()) {
|
||||||
|
defer close(done)
|
||||||
|
var registeredOnce sync.Once
|
||||||
|
for {
|
||||||
|
watchAdded := false
|
||||||
|
i.lb.WatchNotifications(ctx, jsIPNNotifyWatchMask, func() {
|
||||||
|
watchAdded = true
|
||||||
|
refresh()
|
||||||
|
registeredOnce.Do(func() { close(registered) })
|
||||||
|
}, func(n *ipn.Notify) bool {
|
||||||
|
if n.ErrMessage != nil {
|
||||||
|
log.Printf("IPN notification error: %s", *n.ErrMessage)
|
||||||
|
}
|
||||||
|
notify(*n)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !watchAdded {
|
||||||
|
log.Printf("IPN notification watcher stopped before registration")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("IPN notification watcher stopped; reconnecting")
|
||||||
|
timer := time.NewTimer(100 * time.Millisecond)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if !timer.Stop() {
|
||||||
|
<-timer.C
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *jsIPN) refreshNetMap(jsCallbacks js.Value) {
|
||||||
nm := i.lb.NetMapWithPeers()
|
nm := i.lb.NetMapWithPeers()
|
||||||
if nm != nil {
|
if nm == nil {
|
||||||
// Determine which address families we have, for peer peerAPI URL selection.
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var selfHave4, selfHave6 bool
|
var selfHave4, selfHave6 bool
|
||||||
for _, a := range nm.GetAddresses().All() {
|
for _, a := range nm.GetAddresses().All() {
|
||||||
if !a.IsSingleIP() {
|
if !a.IsSingleIP() {
|
||||||
@@ -497,7 +528,6 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Self peerAPI URL: own port as reported by LocalBackend.
|
|
||||||
selfPeerAPIURL := ""
|
selfPeerAPIURL := ""
|
||||||
for _, a := range nm.GetAddresses().All() {
|
for _, a := range nm.GetAddresses().All() {
|
||||||
if !a.IsSingleIP() {
|
if !a.IsSingleIP() {
|
||||||
@@ -524,24 +554,19 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
|
|||||||
Peers: mapSlice(nm.Peers, func(p tailcfg.NodeView) jsNetMapPeerNode {
|
Peers: mapSlice(nm.Peers, func(p tailcfg.NodeView) jsNetMapPeerNode {
|
||||||
name := p.Name()
|
name := p.Name()
|
||||||
if name == "" {
|
if name == "" {
|
||||||
// In practice this should only happen for Hello.
|
|
||||||
name = p.Hostinfo().Hostname()
|
name = p.Hostinfo().Hostname()
|
||||||
}
|
}
|
||||||
addrs := make([]string, p.Addresses().Len())
|
addrs := make([]string, p.Addresses().Len())
|
||||||
for idx, ap := range p.Addresses().All() {
|
for idx, ap := range p.Addresses().All() {
|
||||||
addrs[idx] = ap.Addr().String()
|
addrs[idx] = ap.Addr().String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Peer peerAPI URL from the peer's advertised Services.
|
|
||||||
peerURL := buildPeerAPIURL(p, selfHave4, selfHave6)
|
|
||||||
|
|
||||||
return jsNetMapPeerNode{
|
return jsNetMapPeerNode{
|
||||||
jsNetMapNode: jsNetMapNode{
|
jsNetMapNode: jsNetMapNode{
|
||||||
Name: name,
|
Name: name,
|
||||||
Addresses: addrs,
|
Addresses: addrs,
|
||||||
MachineKey: p.Machine().String(),
|
MachineKey: p.Machine().String(),
|
||||||
NodeKey: p.Key().String(),
|
NodeKey: p.Key().String(),
|
||||||
PeerAPIURL: peerURL,
|
PeerAPIURL: buildPeerAPIURL(p, selfHave4, selfHave6),
|
||||||
Services: userServicesFromView(p.Hostinfo().Services()),
|
Services: userServicesFromView(p.Hostinfo().Services()),
|
||||||
},
|
},
|
||||||
Online: p.Online().Clone(),
|
Online: p.Online().Clone(),
|
||||||
@@ -557,7 +582,31 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
|
|||||||
} else {
|
} else {
|
||||||
log.Printf("Could not generate JSON netmap: %v", err)
|
log.Printf("Could not generate JSON netmap: %v", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *jsIPN) run(jsCallbacks js.Value) {
|
||||||
|
notifyState := func(state ipn.State) {
|
||||||
|
jsCallbacks.Call("notifyState", jsIPNState[state])
|
||||||
}
|
}
|
||||||
|
notifyState(ipn.NoState)
|
||||||
|
|
||||||
|
notify := func(n ipn.Notify) {
|
||||||
|
// Panics in the notify callback are likely due to be due to bugs in
|
||||||
|
// this bridging module (as opposed to actual bugs in Tailscale) and
|
||||||
|
// thus may be recoverable. Let the UI know, and allow the user to
|
||||||
|
// choose if they want to reload the page.
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
fmt.Println("Panic recovered:", r)
|
||||||
|
jsCallbacks.Call("notifyPanicRecover", fmt.Sprint(r))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
log.Printf("NOTIFY: %+v", n)
|
||||||
|
if n.State != nil {
|
||||||
|
notifyState(*n.State)
|
||||||
|
}
|
||||||
|
if notifyRefreshesNetMap(n) {
|
||||||
|
i.refreshNetMap(jsCallbacks)
|
||||||
}
|
}
|
||||||
if n.Prefs != nil && n.Prefs.Valid() {
|
if n.Prefs != nil && n.Prefs.Valid() {
|
||||||
jsCallbacks.Call("notifyExitNode", string(n.Prefs.ExitNodeID()))
|
jsCallbacks.Call("notifyExitNode", string(n.Prefs.ExitNodeID()))
|
||||||
@@ -605,9 +654,32 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
|
|||||||
log.Printf("could not marshal OutgoingFiles: %v", err)
|
log.Printf("could not marshal OutgoingFiles: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
refresh := func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
fmt.Println("Panic recovered:", r)
|
||||||
|
jsCallbacks.Call("notifyPanicRecover", fmt.Sprint(r))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
i.refreshNetMap(jsCallbacks)
|
||||||
|
}
|
||||||
|
|
||||||
|
registered := make(chan struct{})
|
||||||
|
notifyCtx, notifyCancel := context.WithCancel(context.Background())
|
||||||
|
notifyDone := make(chan struct{})
|
||||||
|
i.notifyMu.Lock()
|
||||||
|
i.notifyCancel = notifyCancel
|
||||||
|
i.notifyDone = notifyDone
|
||||||
|
i.notifyMu.Unlock()
|
||||||
|
go i.watchNotifications(notifyCtx, registered, notifyDone, notify, refresh)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
select {
|
||||||
|
case <-registered:
|
||||||
|
case <-notifyDone:
|
||||||
|
return
|
||||||
|
}
|
||||||
err := i.lb.Start(ipn.Options{
|
err := i.lb.Start(ipn.Options{
|
||||||
UpdatePrefs: &ipn.Prefs{
|
UpdatePrefs: &ipn.Prefs{
|
||||||
ControlURL: i.controlURL,
|
ControlURL: i.controlURL,
|
||||||
@@ -646,6 +718,15 @@ func (i *jsIPN) logout() {
|
|||||||
func (i *jsIPN) shutdown() js.Value {
|
func (i *jsIPN) shutdown() js.Value {
|
||||||
return makePromise(func() (any, error) {
|
return makePromise(func() (any, error) {
|
||||||
i.shutdownOnce.Do(func() {
|
i.shutdownOnce.Do(func() {
|
||||||
|
i.notifyMu.Lock()
|
||||||
|
notifyCancel, notifyDone := i.notifyCancel, i.notifyDone
|
||||||
|
i.notifyMu.Unlock()
|
||||||
|
if notifyCancel != nil {
|
||||||
|
notifyCancel()
|
||||||
|
}
|
||||||
|
if notifyDone != nil {
|
||||||
|
<-notifyDone
|
||||||
|
}
|
||||||
if i.lb != nil {
|
if i.lb != nil {
|
||||||
i.lb.Shutdown()
|
i.lb.Shutdown()
|
||||||
}
|
}
|
||||||
@@ -1591,19 +1672,83 @@ func makePromise(f func() (any, error)) js.Value {
|
|||||||
resolve := args[0]
|
resolve := args[0]
|
||||||
reject := args[1]
|
reject := args[1]
|
||||||
go func() {
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
rejectJSError(reject, recoveredJSError(recovered))
|
||||||
|
}
|
||||||
|
}()
|
||||||
if res, err := f(); err == nil {
|
if res, err := f(); err == nil {
|
||||||
resolve.Invoke(res)
|
resolve.Invoke(res)
|
||||||
} else {
|
} else {
|
||||||
reject.Invoke(err.Error())
|
rejectJSError(reject, err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
defer handler.Release()
|
||||||
|
|
||||||
promiseConstructor := js.Global().Get("Promise")
|
promiseConstructor := js.Global().Get("Promise")
|
||||||
return promiseConstructor.New(handler)
|
return promiseConstructor.New(handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func callJSFunction(fn js.Value, args ...any) (ret js.Value, err error) {
|
||||||
|
if fn.Type() != js.TypeFunction {
|
||||||
|
return js.Undefined(), fmt.Errorf("expected JavaScript function, got %s", fn.Type())
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
err = recoveredJSError(recovered)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return fn.Invoke(args...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func callJSMethod(receiver js.Value, method string, args ...any) (ret js.Value, err error) {
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
err = recoveredJSError(recovered)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if receiver.Type() != js.TypeObject && receiver.Type() != js.TypeFunction {
|
||||||
|
return js.Undefined(), fmt.Errorf("cannot call JavaScript method %q on %s", method, receiver.Type())
|
||||||
|
}
|
||||||
|
if receiver.Get(method).Type() != js.TypeFunction {
|
||||||
|
return js.Undefined(), fmt.Errorf("JavaScript property %q is not a function", method)
|
||||||
|
}
|
||||||
|
return receiver.Call(method, args...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasJSFunctionProperty(receiver js.Value, property string) (ok bool, err error) {
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
err = recoveredJSError(recovered)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if receiver.Type() != js.TypeObject && receiver.Type() != js.TypeFunction {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return receiver.Get(property).Type() == js.TypeFunction, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func recoveredJSError(recovered any) error {
|
||||||
|
switch recovered := recovered.(type) {
|
||||||
|
case js.Error:
|
||||||
|
return recovered
|
||||||
|
case *js.ValueError:
|
||||||
|
return recovered
|
||||||
|
default:
|
||||||
|
panic(recovered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejectJSError(reject js.Value, err error) {
|
||||||
|
if jsErr, ok := err.(js.Error); ok {
|
||||||
|
reject.Invoke(jsErr.Value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reject.Invoke(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
const logPolicyStateKey = "log-policy"
|
const logPolicyStateKey = "log-policy"
|
||||||
|
|
||||||
func getOrCreateLogPolicyConfig(state ipn.StateStore) *logpolicy.Config {
|
func getOrCreateLogPolicyConfig(state ipn.StateStore) *logpolicy.Config {
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"tailscale.com/cmd/tsconnect/wasmbuild"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Print(wasmbuild.Tags())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user