From dfcc304a09fe96f34c0c0cd621f6bf3c6576194b Mon Sep 17 00:00:00 2001 From: Pietro di Caprio Date: Tue, 1 Sep 2026 14:38:27 +0200 Subject: [PATCH 1/3] feat: add Capacitor mobile runtime support Add embedded WASM delivery, an opt-in native iOS SSC transport with exact-origin and bounded-queue controls, browser/iOS tests, and a macOS CI gate. Browser WebSocket remains the compatibility default. --- .github/workflows/test-build-release.yml | 19 +- bench/todomvc/rfw/wasm_loader.js | 47 +++- cmd/rfw/build/build.go | 51 ++-- cmd/rfw/build/build_test.go | 179 +++++++++++++- cmd/rfw/build/delivery.go | 131 ++++++++++ cmd/rfw/build/delivery_test.go | 181 ++++++++++++++ cmd/rfw/build/testdata/wasm_loader_test.mjs | 95 ++++++++ cmd/rfw/build/wasm_artifacts.go | 21 ++ cmd/rfw/initproj/template/wasm_loader.js | 47 +++- examples/counter/wasm_loader.js | 47 +++- examples/dashboard/wasm_loader.js | 47 +++- examples/dynamic-list/wasm_loader.js | 47 +++- hostclient/capacitor_transport_wasm_test.go | 184 ++++++++++++++ hostclient/transport.go | 37 ++- hostclient/transport_selector.go | 185 ++++++++++++++ mobile/capacitor-ssc/.gitignore | 2 + mobile/capacitor-ssc/Package.resolved | 14 ++ mobile/capacitor-ssc/Package.swift | 29 +++ mobile/capacitor-ssc/README.md | 57 +++++ mobile/capacitor-ssc/RFWCapacitorSSC.podspec | 13 + .../RFWSSCPlugin/BoundedOutboundQueue.swift | 33 +++ .../RFWSSCPlugin/NativeSSCConnection.swift | 229 ++++++++++++++++++ .../Sources/RFWSSCPlugin/RFWSSCPlugin.swift | 134 ++++++++++ .../RFWSSCPlugin/SSCPluginConfiguration.swift | 173 +++++++++++++ .../BoundedOutboundQueueTests.swift | 30 +++ .../SSCPluginConfigurationTests.swift | 98 ++++++++ mobile/capacitor-ssc/package.json | 26 ++ mobile/capacitor-ssc/scripts/verify-ios.sh | 22 ++ 28 files changed, 2123 insertions(+), 55 deletions(-) create mode 100644 cmd/rfw/build/delivery.go create mode 100644 cmd/rfw/build/delivery_test.go create mode 100644 hostclient/capacitor_transport_wasm_test.go create mode 100644 hostclient/transport_selector.go create mode 100644 mobile/capacitor-ssc/.gitignore create mode 100644 mobile/capacitor-ssc/Package.resolved create mode 100644 mobile/capacitor-ssc/Package.swift create mode 100644 mobile/capacitor-ssc/README.md create mode 100644 mobile/capacitor-ssc/RFWCapacitorSSC.podspec create mode 100644 mobile/capacitor-ssc/ios/Sources/RFWSSCPlugin/BoundedOutboundQueue.swift create mode 100644 mobile/capacitor-ssc/ios/Sources/RFWSSCPlugin/NativeSSCConnection.swift create mode 100644 mobile/capacitor-ssc/ios/Sources/RFWSSCPlugin/RFWSSCPlugin.swift create mode 100644 mobile/capacitor-ssc/ios/Sources/RFWSSCPlugin/SSCPluginConfiguration.swift create mode 100644 mobile/capacitor-ssc/ios/Tests/RFWSSCPluginTests/BoundedOutboundQueueTests.swift create mode 100644 mobile/capacitor-ssc/ios/Tests/RFWSSCPluginTests/SSCPluginConfigurationTests.swift create mode 100644 mobile/capacitor-ssc/package.json create mode 100755 mobile/capacitor-ssc/scripts/verify-ios.sh diff --git a/.github/workflows/test-build-release.yml b/.github/workflows/test-build-release.yml index beb74d44..b0133294 100644 --- a/.github/workflows/test-build-release.yml +++ b/.github/workflows/test-build-release.yml @@ -94,6 +94,23 @@ jobs: GOOS=js GOARCH=wasm go test -p=1 \ -exec "$PWD/.github/scripts/wasmbrowsertest-retry" ./... + ios-native-ssc: + name: Test (iOS native SSC) + runs-on: macos-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Run iOS plugin tests + working-directory: mobile/capacitor-ssc + run: npm run verify:ios + scan: name: EUProvGuard Scan runs-on: ubuntu-latest @@ -133,7 +150,7 @@ jobs: name: Release if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest - needs: [test, wasm, scan, lint, secret-scan] + needs: [test, wasm, ios-native-ssc, scan, lint, secret-scan] permissions: contents: write container: diff --git a/bench/todomvc/rfw/wasm_loader.js b/bench/todomvc/rfw/wasm_loader.js index 191039a2..4911634b 100644 --- a/bench/todomvc/rfw/wasm_loader.js +++ b/bench/todomvc/rfw/wasm_loader.js @@ -79,15 +79,41 @@ : [], negotiated: global.RFW_WASM_NEGOTIATED === true, production: global.RFW_BUILD_MODE === "production", + embedded: global.RFW_WASM_DELIVERY === "embedded", }; } + // deliveryGuidance names the next thing to check when the bundle cannot be + // loaded. The two delivery modes fail for unrelated reasons, and a packaged + // application has no address bar or network panel to investigate with. + function deliveryGuidance(config) { + if (config.embedded) { + return ( + "Check that `rfw build` ran with build.delivery set to embedded in rfw.json, " + + "that build/client/app.wasm exists, and that the native container packaged it " + + "unmodified." + ); + } + return ( + "Check that `rfw build` produced app.wasm.br and app.wasm.gz and that the server " + + "sends Content-Encoding for them." + ); + } + // buildPlan orders the ways this bundle can be fetched, best first. A // negotiated request is preferred because the browser picks the coding it // actually supports; named artifacts follow for static hosting. function buildPlan(url, config) { const trimmed = url.trim(); if (!trimmed) return []; + // An embedded build packages the raw bundle with the application: + // there is no server to negotiate with and no compressed artifact on + // disk. The raw wasm is the whole plan, in production as much as in + // development, because it is a local read rather than the + // multi-megabyte download the production rule exists to prevent. + if (config.embedded) { + return [{ url: trimmed, kind: "embedded" }]; + } const queryIndex = trimmed.indexOf("?"); const base = queryIndex === -1 ? trimmed : trimmed.slice(0, queryIndex); const query = queryIndex === -1 ? "" : trimmed.slice(queryIndex); @@ -133,6 +159,12 @@ if (step.kind === "identity") { return { response, encoded: false }; } + if (step.kind === "embedded") { + // A native asset handler serves the packaged file as it is. Report + // whatever it did send, so the progress bar stays honest on a + // handler that happens to label the response. + return { response, encoded }; + } if (encoding === step.kind) { // The server labelled it, so the browser already decoded it. return { response, encoded: true }; @@ -316,8 +348,7 @@ const error = new Error( `Failed to load the WebAssembly bundle. Tried ${plan .map((step) => step.kind) - .join(", ")}. ${detail}. ` + - "Check that `rfw build` produced app.wasm.br and app.wasm.gz and that the server sends Content-Encoding for them.", + .join(", ")}. ${detail}. ` + deliveryGuidance(config), ); console.error(error); showRuntimeFailure(error); @@ -363,7 +394,17 @@ } catch (err) { if (bar) bar.finish(false); console.error("Failed to instantiate Wasm bundle", err); - throw err; + // A corrupt bundle fails here rather than at fetch time. Recovery + // has to be as visible as a failed download; otherwise the only + // symptom is a blank page. + const failure = new Error( + `Failed to instantiate the WebAssembly bundle from ${url}: ` + + `${err.message}. ` + + deliveryGuidance(config), + { cause: err }, + ); + showRuntimeFailure(failure); + throw failure; } if (bar) bar.finish(true); const key = recoveryKey(url); diff --git a/cmd/rfw/build/build.go b/cmd/rfw/build/build.go index 79daf56f..d998c895 100644 --- a/cmd/rfw/build/build.go +++ b/cmd/rfw/build/build.go @@ -48,17 +48,15 @@ func pluginsConfig(explicit map[string]json.RawMessage) map[string]json.RawMessa // Build compiles the configured application and runs its build plugins. func Build() error { - var manifest struct { - Build struct { - Type string `json:"type"` - Host string `json:"host"` - } `json:"build"` - Plugins map[string]json.RawMessage `json:"plugins"` - } - if data, err := os.ReadFile("rfw.json"); err == nil { - _ = json.Unmarshal(data, &manifest) + shape := buildShape{delivery: deliveryNetwork, transport: sscTransportBrowser} + if data, readErr := os.ReadFile("rfw.json"); readErr == nil { + decoded, err := decodeBuildShape(data) + if err != nil { + return err + } + shape = decoded } - if err := plugins.Configure(pluginsConfig(manifest.Plugins)); err != nil { + if err := plugins.Configure(pluginsConfig(shape.plugins)); err != nil { return fmt.Errorf("failed to configure plugins: %w", err) } if err := plugins.PreBuild(); err != nil { @@ -87,7 +85,7 @@ func Build() error { // working. core no longer imports hostclient, so nothing else would pull it // in, and a "type": "static" project drops the websocket and net/http // stacks from its bundle. - if manifest.Build.Type != "static" { + if !shape.static { sscFile, err := writeSSCImport() if err != nil { return fmt.Errorf("failed to write SSC import: %w", err) @@ -114,24 +112,15 @@ func Build() error { } isDev := utils.IsDebug() || os.Getenv("RFW_DEV_BUILD") == "1" - var encodings []Encoding - if !isDev { - encodings, err = compressWasm(wasmPath) - if err != nil { - return fmt.Errorf("failed to compress wasm: %w", err) - } - } else if err := removeStaleArtifacts(wasmPath); err != nil { - // Dev never recompresses the wasm, but the loader prefers a compressed - // artifact over the raw one. An artifact left by an earlier production - // build would be served stale (and, in dev, under an immutable cache - // header), so drop it and let the fresh uncompressed wasm be what loads. - return fmt.Errorf("failed to remove stale wasm artifacts: %w", err) + encodings, err := prepareWasmArtifacts(wasmPath, shape, isDev) + if err != nil { + return err } // Build the host binary for SSC when a host directory exists. A static // (client-only) build skips it, so the output is a pure static bundle that // can be served from a CDN with no live host. - if manifest.Build.Type != "static" { + if !shape.static { if _, err := os.Stat("host"); err == nil { if err := makePublicDir(hostDir); err != nil { return fmt.Errorf("failed to create host build directory: %w", err) @@ -175,9 +164,9 @@ func Build() error { version := fmt.Sprintf("%x", wasmHash[:8]) // A live host negotiates the encoding from Accept-Encoding. A static build // is served by something that will not, so the client picks an artifact by - // name instead. - negotiated := manifest.Build.Type != "static" - if err := writeClientConfig(clientDir, manifest.Build.Host, version, encodings, negotiated, !isDev); err != nil { + // name instead, and an embedded build is read from the container's local + // asset handler, which negotiates nothing either. + if err := writeClientConfig(clientDir, shape.host, version, encodings, shape.negotiates(), !isDev, shape.delivery, shape.transport); err != nil { return fmt.Errorf("failed to write client config: %w", err) } if err := stampIndexHTML(filepath.Join(clientDir, "index.html"), version); err != nil { @@ -312,20 +301,24 @@ func readWasmExec() ([]byte, error) { // writeClientConfig describes the build to the loader. Every name it emits is // defined: a loader that reads an undefined global cannot tell "off" from // "absent", which is how an application ends up silently downloading the raw -// bundle it has a compressed copy of. -func writeClientConfig(clientDir, host, wasmVersion string, encodings []Encoding, negotiated, production bool) error { +// bundle it has a compressed copy of. That is also why the delivery mode is +// stamped explicitly instead of being inferred from an empty encoding list, +// which a failed compression step would produce too. +func writeClientConfig(clientDir, host, wasmVersion string, encodings []Encoding, negotiated, production bool, deliveryMode delivery, transport sscTransport) error { var b strings.Builder b.WriteString("// Generated by rfw build. Do not edit.\n") if h := strings.TrimSpace(host); h != "" { fmt.Fprintf(&b, "window.RFW_HOST_URL = %q;\n", h) } fmt.Fprintf(&b, "window.RFW_WASM_VERSION = %q;\n", wasmVersion) + fmt.Fprintf(&b, "window.RFW_WASM_DELIVERY = %q;\n", string(deliveryMode)) names := make([]string, 0, len(encodings)) for _, encoding := range encodings { names = append(names, strconv.Quote(string(encoding))) } fmt.Fprintf(&b, "window.RFW_WASM_ENCODINGS = [%s];\n", strings.Join(names, ", ")) fmt.Fprintf(&b, "window.RFW_WASM_NEGOTIATED = %t;\n", negotiated) + fmt.Fprintf(&b, "window.RFW_SSC_TRANSPORT = %q;\n", string(transport)) mode := "development" if production { mode = "production" diff --git a/cmd/rfw/build/build_test.go b/cmd/rfw/build/build_test.go index 6534be15..82ce678b 100644 --- a/cmd/rfw/build/build_test.go +++ b/cmd/rfw/build/build_test.go @@ -7,6 +7,7 @@ import ( "go/parser" "go/token" "io" + "io/fs" "os" "os/exec" "path/filepath" @@ -85,6 +86,61 @@ func TestWriteWasmLoaderPreservesProjectOverride(t *testing.T) { } } +// The examples and the benchmark carry their own copy of the loader, which is +// what they actually run. A copy left behind by an earlier change would keep +// passing every test while implementing a different delivery contract, so the +// repository owns one loader and every copy of it is that one. +func TestRepositoryLoaderCopiesMatchTheFrameworkLoader(t *testing.T) { + want, err := initproj.TemplatesFS.ReadFile("template/wasm_loader.js") + if err != nil { + t.Fatalf("read embedded loader: %v", err) + } + root, err := os.OpenRoot(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatalf("open the repository root: %v", err) + } + defer func() { + if closeErr := root.Close(); closeErr != nil { + t.Errorf("close the repository root: %v", closeErr) + } + }() + // Walking and reading through the root keeps every path relative to the + // repository, so no read can reach outside the checkout. + repo := root.FS() + copies := 0 + if err := fs.WalkDir(repo, ".", func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + // build directories hold generated copies and node_modules holds + // somebody else's files. + switch entry.Name() { + case ".git", "build", "node_modules": + return fs.SkipDir + } + return nil + } + if entry.Name() != "wasm_loader.js" { + return nil + } + copies++ + got, err := fs.ReadFile(repo, path) + if err != nil { + return err + } + if string(got) != string(want) { + t.Errorf("%s has diverged from the framework loader", path) + } + return nil + }); err != nil { + t.Fatalf("walk the repository: %v", err) + } + if copies < 2 { + t.Fatalf("found %d loaders, expected the framework loader and the copies that use it", copies) + } +} + // TestCopyFile ensures copyFile replicates the source file's contents at the // destination path. func TestCopyFile(t *testing.T) { @@ -183,11 +239,97 @@ func TestCompressWasmWritesEveryArtifact(t *testing.T) { } } +// A production build advertises what it wrote. Embedded delivery writes only +// the raw bundle, and it has to leave the directory in that state even when an +// earlier network build filled it with compressed copies: a packaged +// application would otherwise ship megabytes nothing will ever request. +func TestPrepareWasmArtifacts(t *testing.T) { + wasm := []byte(strings.Repeat("rfw wasm", 32)) + for _, tc := range []struct { + name string + shape buildShape + isDev bool + want []Encoding + compressed bool + }{ + { + name: "network production compresses", + shape: buildShape{delivery: deliveryNetwork}, + want: []Encoding{EncodingBrotli, EncodingGzip}, + compressed: true, + }, + { + name: "embedded production packages only the raw bundle", + shape: buildShape{delivery: deliveryEmbedded}, + }, + { + name: "embedded static behaves like embedded ssc", + shape: buildShape{static: true, delivery: deliveryEmbedded}, + }, + { + name: "development never compresses", + shape: buildShape{delivery: deliveryNetwork}, + isDev: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + src := "app.wasm" + if err := os.WriteFile(src, wasm, 0o600); err != nil { + t.Fatalf("write wasm: %v", err) + } + for _, ext := range []string{".br", ".gz"} { + if err := os.WriteFile(src+ext, []byte("stale"), 0o600); err != nil { + t.Fatalf("write stale %s: %v", ext, err) + } + } + + got, err := prepareWasmArtifacts(src, tc.shape, tc.isDev) + if err != nil { + t.Fatalf("prepareWasmArtifacts: %v", err) + } + if len(got) != len(tc.want) { + t.Fatalf("encodings = %v, want %v", got, tc.want) + } + for i, encoding := range tc.want { + if got[i] != encoding { + t.Fatalf("encodings = %v, want %v", got, tc.want) + } + } + for _, ext := range []string{".br", ".gz"} { + // #nosec G304 -- src is a test-owned temporary file. + data, err := os.ReadFile(src + ext) + switch { + case tc.compressed: + if err != nil { + t.Fatalf("read %s: %v", src+ext, err) + } + if string(data) == "stale" { + t.Fatalf("%s was left from the previous build", src+ext) + } + case !os.IsNotExist(err): + t.Fatalf("expected %s removed, stat err=%v", src+ext, err) + } + } + // The raw bundle is the artifact an embedded build ships, so it + // has to survive untouched. + raw, err := os.ReadFile(src) + if err != nil { + t.Fatalf("read wasm: %v", err) + } + if string(raw) != string(wasm) { + t.Fatal("the raw wasm was rewritten") + } + }) + } +} + func TestWriteClientConfig(t *testing.T) { dir := t.TempDir() t.Chdir(dir) encodings := []Encoding{EncodingBrotli, EncodingGzip} - if err := writeClientConfig(".", "wss://example.com/rfw", "abc123", encodings, true, true); err != nil { + if err := writeClientConfig(".", "wss://example.com/rfw", "abc123", encodings, true, true, deliveryNetwork, sscTransportBrowser); err != nil { t.Fatalf("writeClientConfig: %v", err) } @@ -199,8 +341,10 @@ func TestWriteClientConfig(t *testing.T) { for _, want := range []string{ `window.RFW_HOST_URL = "wss://example.com/rfw";`, `window.RFW_WASM_VERSION = "abc123";`, + `window.RFW_WASM_DELIVERY = "network";`, `window.RFW_WASM_ENCODINGS = ["br", "gzip"];`, `window.RFW_WASM_NEGOTIATED = true;`, + `window.RFW_SSC_TRANSPORT = "browser";`, `window.RFW_BUILD_MODE = "production";`, } { if !strings.Contains(got, want) { @@ -215,7 +359,7 @@ func TestWriteClientConfig(t *testing.T) { func TestWriteClientConfigDescribesAnUncompressedBuild(t *testing.T) { dir := t.TempDir() t.Chdir(dir) - if err := writeClientConfig(".", "", "devbuild", nil, false, false); err != nil { + if err := writeClientConfig(".", "", "devbuild", nil, false, false, deliveryNetwork, sscTransportBrowser); err != nil { t.Fatalf("writeClientConfig: %v", err) } config, err := os.ReadFile("rfw_config.js") @@ -237,12 +381,41 @@ func TestWriteClientConfigDescribesAnUncompressedBuild(t *testing.T) { } } +// An embedded production build has no artifact to advertise and nothing to +// negotiate with, but it is still a production build: the mode stays honest and +// the delivery global is what tells the loader why the encoding list is empty. +func TestWriteClientConfigDescribesAnEmbeddedBuild(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + if err := writeClientConfig(".", "wss://api.example.com/ws", "abc123", nil, false, true, deliveryEmbedded, sscTransportCapacitor); err != nil { + t.Fatalf("writeClientConfig: %v", err) + } + config, err := os.ReadFile("rfw_config.js") + if err != nil { + t.Fatalf("read client config: %v", err) + } + got := string(config) + for _, want := range []string{ + // An embedded SSC application still talks to its remote host. + `window.RFW_HOST_URL = "wss://api.example.com/ws";`, + `window.RFW_WASM_DELIVERY = "embedded";`, + `window.RFW_WASM_ENCODINGS = [];`, + `window.RFW_WASM_NEGOTIATED = false;`, + `window.RFW_SSC_TRANSPORT = "capacitor";`, + `window.RFW_BUILD_MODE = "production";`, + } { + if !strings.Contains(got, want) { + t.Fatalf("client config is missing %q:\n%s", want, got) + } + } +} + // Every global the loader reads has to be defined by the config, or the loader // cannot tell a disabled feature from a stale build. func TestWriteClientConfigDefinesEveryGlobalTheLoaderReads(t *testing.T) { dir := t.TempDir() t.Chdir(dir) - if err := writeClientConfig(".", "", "abc123", []Encoding{EncodingGzip}, false, true); err != nil { + if err := writeClientConfig(".", "", "abc123", []Encoding{EncodingGzip}, false, true, deliveryNetwork, sscTransportBrowser); err != nil { t.Fatalf("writeClientConfig: %v", err) } config, err := os.ReadFile("rfw_config.js") diff --git a/cmd/rfw/build/delivery.go b/cmd/rfw/build/delivery.go new file mode 100644 index 00000000..c29e28c6 --- /dev/null +++ b/cmd/rfw/build/delivery.go @@ -0,0 +1,131 @@ +//go:build !js + +// Package build compiles RFW applications and runs build plugins. This file +// covers how the built client reaches the browser: over the network from a host +// or a static origin, or from assets packaged next to the application. +package build + +import ( + "encoding/json" + "fmt" + "strings" +) + +// delivery names how the client bundle reaches the browser. It is orthogonal to +// build.type: type decides what is linked into the bundle, delivery decides how +// the bundle is transferred. +type delivery string + +// sscTransport names the runtime used for the client-to-host SSC connection. +// It is independent of delivery: an embedded application may still use the +// browser WebSocket, while a Capacitor application can opt into the native +// URLSession bridge when its authenticated cookie cannot cross WKWebView's +// origin boundary. +type sscTransport string + +const ( + // deliveryNetwork is the default. The bundle is downloaded, so the build + // writes compressed artifacts and the loader picks one. + deliveryNetwork delivery = "network" + // deliveryEmbedded packages the bundle with the application, as a native + // container does. The packaged file is still read through the container's + // local URL-scheme asset handler, but there is no network transfer to + // compress and nothing that negotiates a content coding, so the build + // writes only the optimized raw wasm and the loader loads exactly that. + deliveryEmbedded delivery = "embedded" + + sscTransportBrowser sscTransport = "browser" + sscTransportCapacitor sscTransport = "capacitor" +) + +// parseDelivery resolves the build.delivery value from rfw.json. An empty value +// keeps the network default, so a manifest written before this setting existed +// builds exactly as it did. +func parseDelivery(value string) (delivery, error) { + switch mode := delivery(strings.TrimSpace(value)); mode { + case "": + return deliveryNetwork, nil + case deliveryNetwork, deliveryEmbedded: + return mode, nil + default: + return "", fmt.Errorf( + "rfw.json: build.delivery %q is not a delivery mode; use %q "+ + "(the default: compressed artifacts fetched over the network) or %q "+ + "(the raw bundle packaged with the application)", + value, deliveryNetwork, deliveryEmbedded, + ) + } +} + +func parseSSCTransport(value string) (sscTransport, error) { + switch transport := sscTransport(strings.TrimSpace(value)); transport { + case "", sscTransportBrowser: + return sscTransportBrowser, nil + case sscTransportCapacitor: + return transport, nil + default: + return "", fmt.Errorf( + "rfw.json: build.sscTransport %q is not an SSC transport; use %q "+ + "(the default browser WebSocket) or %q (the RFW Capacitor plugin)", + value, sscTransportBrowser, sscTransportCapacitor, + ) + } +} + +// buildShape is the delivery-relevant view of rfw.json. It exists so the two +// settings that shape a build stay separable: "static" decides whether the SSC +// client is linked, "embedded" decides how the bundle is delivered, and neither +// implies the other. +type buildShape struct { + static bool + host string + delivery delivery + transport sscTransport + plugins map[string]json.RawMessage +} + +// negotiates reports whether the client can leave the choice of content coding +// to the server. A static origin does not negotiate, and the local asset +// handler that serves packaged assets does not either. +func (s buildShape) negotiates() bool { + return !s.static && s.delivery == deliveryNetwork +} + +// compresses reports whether the build should write compressed artifacts. +// Packaging them would ship bytes nothing will ever request. +func (s buildShape) compresses() bool { + return s.delivery == deliveryNetwork +} + +// decodeBuildShape reads rfw.json. A manifest that does not parse falls back to +// the defaults, as it always has, but a manifest that parses and names an +// unknown delivery mode fails the build rather than quietly ignoring it. +func decodeBuildShape(data []byte) (buildShape, error) { + var manifest struct { + Build struct { + Type string `json:"type"` + Host string `json:"host"` + Delivery string `json:"delivery"` + SSCTransport string `json:"sscTransport"` + } `json:"build"` + Plugins map[string]json.RawMessage `json:"plugins"` + } + if err := json.Unmarshal(data, &manifest); err != nil { + return buildShape{delivery: deliveryNetwork, transport: sscTransportBrowser}, nil + } + mode, err := parseDelivery(manifest.Build.Delivery) + if err != nil { + return buildShape{}, err + } + transport, err := parseSSCTransport(manifest.Build.SSCTransport) + if err != nil { + return buildShape{}, err + } + return buildShape{ + static: manifest.Build.Type == "static", + host: manifest.Build.Host, + delivery: mode, + transport: transport, + plugins: manifest.Plugins, + }, nil +} diff --git a/cmd/rfw/build/delivery_test.go b/cmd/rfw/build/delivery_test.go new file mode 100644 index 00000000..62f223c1 --- /dev/null +++ b/cmd/rfw/build/delivery_test.go @@ -0,0 +1,181 @@ +//go:build !js + +package build + +import ( + "strings" + "testing" +) + +// A manifest written before build.delivery existed keeps building exactly as it +// did, and a value that is not a delivery mode fails the build with something +// the author can act on. +func TestParseDelivery(t *testing.T) { + for _, tc := range []struct { + name string + value string + want delivery + }{ + {name: "absent", value: "", want: deliveryNetwork}, + {name: "explicit network", value: "network", want: deliveryNetwork}, + {name: "embedded", value: "embedded", want: deliveryEmbedded}, + {name: "surrounding whitespace", value: " embedded\n", want: deliveryEmbedded}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := parseDelivery(tc.value) + if err != nil { + t.Fatalf("parseDelivery(%q): %v", tc.value, err) + } + if got != tc.want { + t.Fatalf("parseDelivery(%q) = %q, want %q", tc.value, got, tc.want) + } + }) + } +} + +func TestParseDeliveryRejectsUnknownModes(t *testing.T) { + for _, value := range []string{"Embedded", "local", "offline", "true", "net work"} { + _, err := parseDelivery(value) + if err == nil { + t.Fatalf("parseDelivery(%q) was accepted", value) + } + message := err.Error() + for _, want := range []string{"build.delivery", value, string(deliveryNetwork), string(deliveryEmbedded)} { + if !strings.Contains(message, want) { + t.Fatalf("error %q does not mention %q", message, want) + } + } + } +} + +func TestParseSSCTransport(t *testing.T) { + for _, tc := range []struct { + value string + want sscTransport + }{ + {value: "", want: sscTransportBrowser}, + {value: "browser", want: sscTransportBrowser}, + {value: " capacitor ", want: sscTransportCapacitor}, + } { + got, err := parseSSCTransport(tc.value) + if err != nil { + t.Fatalf("parseSSCTransport(%q): %v", tc.value, err) + } + if got != tc.want { + t.Fatalf("parseSSCTransport(%q) = %q, want %q", tc.value, got, tc.want) + } + } +} + +func TestParseSSCTransportRejectsUnknownModes(t *testing.T) { + for _, value := range []string{"native", "Capacitor", "auto", "true"} { + _, err := parseSSCTransport(value) + if err == nil { + t.Fatalf("parseSSCTransport(%q) was accepted", value) + } + message := err.Error() + for _, want := range []string{"build.sscTransport", value, string(sscTransportBrowser), string(sscTransportCapacitor)} { + if !strings.Contains(message, want) { + t.Fatalf("error %q does not mention %q", message, want) + } + } + } +} + +// Delivery mode and type are separate decisions. An embedded SSC application +// still links the host client and still configures its WebSocket endpoint; an +// embedded static application still drops both. +func TestDecodeBuildShapeKeepsDeliveryAndTypeSeparate(t *testing.T) { + for _, tc := range []struct { + name string + manifest string + want buildShape + }{ + { + name: "ssc defaults to network delivery", + manifest: `{"build":{"type":"ssc","host":"wss://api.example.com/ws"}}`, + want: buildShape{host: "wss://api.example.com/ws", delivery: deliveryNetwork, transport: sscTransportBrowser}, + }, + { + name: "embedded ssc keeps the host client and the host url", + manifest: `{"build":{"type":"ssc","host":"wss://api.example.com/ws","delivery":"embedded"}}`, + want: buildShape{host: "wss://api.example.com/ws", delivery: deliveryEmbedded, transport: sscTransportBrowser}, + }, + { + name: "embedded static stays static", + manifest: `{"build":{"type":"static","delivery":"embedded"}}`, + want: buildShape{static: true, delivery: deliveryEmbedded, transport: sscTransportBrowser}, + }, + { + name: "an empty manifest is a network build", + manifest: `{}`, + want: buildShape{delivery: deliveryNetwork, transport: sscTransportBrowser}, + }, + { + name: "embedded SSC can select the Capacitor transport", + manifest: `{"build":{"type":"ssc","host":"wss://api.example.com/ws","delivery":"embedded","sscTransport":"capacitor"}}`, + want: buildShape{host: "wss://api.example.com/ws", delivery: deliveryEmbedded, transport: sscTransportCapacitor}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := decodeBuildShape([]byte(tc.manifest)) + if err != nil { + t.Fatalf("decodeBuildShape: %v", err) + } + if got.static != tc.want.static { + t.Fatalf("static = %t, want %t", got.static, tc.want.static) + } + if got.host != tc.want.host { + t.Fatalf("host = %q, want %q", got.host, tc.want.host) + } + if got.delivery != tc.want.delivery { + t.Fatalf("delivery = %q, want %q", got.delivery, tc.want.delivery) + } + if got.transport != tc.want.transport { + t.Fatalf("transport = %q, want %q", got.transport, tc.want.transport) + } + }) + } +} + +func TestDecodeBuildShapeRejectsAnUnknownDelivery(t *testing.T) { + if _, err := decodeBuildShape([]byte(`{"build":{"type":"ssc","delivery":"capacitor"}}`)); err == nil { + t.Fatal("an unknown delivery mode built without an error") + } +} + +func TestDecodeBuildShapeRejectsAnUnknownSSCTransport(t *testing.T) { + if _, err := decodeBuildShape([]byte(`{"build":{"type":"ssc","sscTransport":"native"}}`)); err == nil { + t.Fatal("an unknown SSC transport built without an error") + } +} + +// An unreadable manifest has always fallen back to the defaults rather than +// failing the build, and it still does. +func TestDecodeBuildShapeToleratesAnUnparsableManifest(t *testing.T) { + got, err := decodeBuildShape([]byte("not json")) + if err != nil { + t.Fatalf("decodeBuildShape: %v", err) + } + if got.delivery != deliveryNetwork || got.transport != sscTransportBrowser || got.static || got.plugins != nil { + t.Fatalf("shape = %+v, want the defaults", got) + } +} + +// Negotiation needs a live host on the other end. A static origin has none, and +// packaged assets come from a local asset handler that negotiates nothing. +func TestBuildShapeNegotiates(t *testing.T) { + for _, tc := range []struct { + shape buildShape + want bool + }{ + {shape: buildShape{delivery: deliveryNetwork}, want: true}, + {shape: buildShape{static: true, delivery: deliveryNetwork}, want: false}, + {shape: buildShape{delivery: deliveryEmbedded}, want: false}, + {shape: buildShape{static: true, delivery: deliveryEmbedded}, want: false}, + } { + if got := tc.shape.negotiates(); got != tc.want { + t.Fatalf("%+v negotiates = %t, want %t", tc.shape, got, tc.want) + } + } +} diff --git a/cmd/rfw/build/testdata/wasm_loader_test.mjs b/cmd/rfw/build/testdata/wasm_loader_test.mjs index 653d9dab..949c1522 100644 --- a/cmd/rfw/build/testdata/wasm_loader_test.mjs +++ b/cmd/rfw/build/testdata/wasm_loader_test.mjs @@ -57,6 +57,7 @@ async function load(config, fetcher) { window.RFW_WASM_ENCODINGS = config.encodings; window.RFW_WASM_NEGOTIATED = config.negotiated; window.RFW_BUILD_MODE = config.production ? "production" : "development"; + window.RFW_WASM_DELIVERY = config.delivery ?? "network"; const requests = []; globalThis.fetch = async (url) => { requests.push(url); @@ -139,3 +140,97 @@ assert.deepEqual( ), ["/app.wasm?v=test"], ); + +// An embedded production build loads the raw bundle and nothing else. The +// encodings and the negotiation flag are deliberately set to what a network +// build would emit: delivery decides, not the leftovers. +assert.deepEqual( + await load( + { + encodings: ["br", "gzip"], + negotiated: true, + production: true, + delivery: "embedded", + }, + (url) => { + assert.equal(url, "/app.wasm?v=test"); + return new Response(wasm, { + status: 200, + headers: { "content-type": "application/wasm" }, + }); + }, + ), + ["/app.wasm?v=test"], +); + +// A packaged bundle that is missing fails visibly, and the error names the +// packaged path rather than sending the reader to look for server headers. +window.RFW_WASM_ENCODINGS = []; +window.RFW_WASM_NEGOTIATED = false; +window.RFW_BUILD_MODE = "production"; +window.RFW_WASM_DELIVERY = "embedded"; +const embeddedRequests = []; +globalThis.fetch = async (url) => { + embeddedRequests.push(url); + return new Response(null, { status: 404 }); +}; +await assert.rejects( + WasmLoader.load("/app.wasm?v=test", { + go: { importObject: {}, run() {} }, + skipLoader: true, + }), + /Tried embedded.+build\.delivery set to embedded/s, +); +assert.deepEqual(embeddedRequests, ["/app.wasm?v=test"]); + +// A corrupt packaged bundle fails at instantiation instead of at fetch, and +// that path has to be as visible as a failed download. +globalThis.fetch = async () => + new Response(Buffer.from("corrupt"), { + status: 200, + headers: { "content-type": "application/wasm" }, + }); +const instantiateStreaming = WebAssembly.instantiateStreaming; +const instantiate = WebAssembly.instantiate; +WebAssembly.instantiateStreaming = async () => { + throw new Error("magic word not detected"); +}; +WebAssembly.instantiate = async () => { + throw new Error("magic word not detected"); +}; +await assert.rejects( + WasmLoader.load("/app.wasm?v=test", { + go: { importObject: {}, run() {} }, + skipLoader: true, + }), + /Failed to instantiate.+build\.delivery set to embedded/s, +); +WebAssembly.instantiateStreaming = instantiateStreaming; +WebAssembly.instantiate = instantiate; + +// A configuration written by an older build defines no delivery mode. That is +// the network contract, unchanged. +delete window.RFW_WASM_DELIVERY; +window.RFW_WASM_ENCODINGS = ["br", "gzip"]; +window.RFW_WASM_NEGOTIATED = true; +window.RFW_BUILD_MODE = "production"; +const legacyRequests = []; +globalThis.fetch = async (url) => { + legacyRequests.push(url); + return new Response(wasm, { + status: 200, + headers: { + "content-encoding": "br", + "content-type": "application/wasm", + }, + }); +}; +instantiated = undefined; +await WasmLoader.load("/app.wasm?v=test", { + go: { importObject: {}, run: () => new Promise(() => {}) }, + skipLoader: true, + reloadOnExit: false, + recoveryWindow: 1, +}); +assert.deepEqual(instantiated, wasm); +assert.deepEqual(legacyRequests, ["/app.wasm?v=test"]); diff --git a/cmd/rfw/build/wasm_artifacts.go b/cmd/rfw/build/wasm_artifacts.go index 99cc1564..1305c27c 100644 --- a/cmd/rfw/build/wasm_artifacts.go +++ b/cmd/rfw/build/wasm_artifacts.go @@ -8,6 +8,7 @@ package build import ( "compress/gzip" "errors" + "fmt" "io" "os" "regexp" @@ -51,6 +52,26 @@ var compressors = map[Encoding]func(io.Writer) io.WriteCloser{ }, } +// prepareWasmArtifacts writes the artifacts a build will advertise and reports +// the encodings that exist on disk afterwards. Compression is the production +// default; a dev build never recompresses, and an embedded build has no +// network transfer to compress. Both of those cases drop artifacts an earlier +// build left behind, because the loader prefers a compressed artifact over the +// raw one and a stale copy would be served under an immutable cache header. +func prepareWasmArtifacts(wasmPath string, shape buildShape, isDev bool) ([]Encoding, error) { + if isDev || !shape.compresses() { + if err := removeStaleArtifacts(wasmPath); err != nil { + return nil, fmt.Errorf("failed to remove stale wasm artifacts: %w", err) + } + return nil, nil + } + encodings, err := compressWasm(wasmPath) + if err != nil { + return nil, fmt.Errorf("failed to compress wasm: %w", err) + } + return encodings, nil +} + // compressWasm writes every production artifact next to src and reports the // encodings that exist afterwards. A build that produces no compressed // artifact is reported as such rather than being papered over, because the diff --git a/cmd/rfw/initproj/template/wasm_loader.js b/cmd/rfw/initproj/template/wasm_loader.js index 191039a2..4911634b 100644 --- a/cmd/rfw/initproj/template/wasm_loader.js +++ b/cmd/rfw/initproj/template/wasm_loader.js @@ -79,15 +79,41 @@ : [], negotiated: global.RFW_WASM_NEGOTIATED === true, production: global.RFW_BUILD_MODE === "production", + embedded: global.RFW_WASM_DELIVERY === "embedded", }; } + // deliveryGuidance names the next thing to check when the bundle cannot be + // loaded. The two delivery modes fail for unrelated reasons, and a packaged + // application has no address bar or network panel to investigate with. + function deliveryGuidance(config) { + if (config.embedded) { + return ( + "Check that `rfw build` ran with build.delivery set to embedded in rfw.json, " + + "that build/client/app.wasm exists, and that the native container packaged it " + + "unmodified." + ); + } + return ( + "Check that `rfw build` produced app.wasm.br and app.wasm.gz and that the server " + + "sends Content-Encoding for them." + ); + } + // buildPlan orders the ways this bundle can be fetched, best first. A // negotiated request is preferred because the browser picks the coding it // actually supports; named artifacts follow for static hosting. function buildPlan(url, config) { const trimmed = url.trim(); if (!trimmed) return []; + // An embedded build packages the raw bundle with the application: + // there is no server to negotiate with and no compressed artifact on + // disk. The raw wasm is the whole plan, in production as much as in + // development, because it is a local read rather than the + // multi-megabyte download the production rule exists to prevent. + if (config.embedded) { + return [{ url: trimmed, kind: "embedded" }]; + } const queryIndex = trimmed.indexOf("?"); const base = queryIndex === -1 ? trimmed : trimmed.slice(0, queryIndex); const query = queryIndex === -1 ? "" : trimmed.slice(queryIndex); @@ -133,6 +159,12 @@ if (step.kind === "identity") { return { response, encoded: false }; } + if (step.kind === "embedded") { + // A native asset handler serves the packaged file as it is. Report + // whatever it did send, so the progress bar stays honest on a + // handler that happens to label the response. + return { response, encoded }; + } if (encoding === step.kind) { // The server labelled it, so the browser already decoded it. return { response, encoded: true }; @@ -316,8 +348,7 @@ const error = new Error( `Failed to load the WebAssembly bundle. Tried ${plan .map((step) => step.kind) - .join(", ")}. ${detail}. ` + - "Check that `rfw build` produced app.wasm.br and app.wasm.gz and that the server sends Content-Encoding for them.", + .join(", ")}. ${detail}. ` + deliveryGuidance(config), ); console.error(error); showRuntimeFailure(error); @@ -363,7 +394,17 @@ } catch (err) { if (bar) bar.finish(false); console.error("Failed to instantiate Wasm bundle", err); - throw err; + // A corrupt bundle fails here rather than at fetch time. Recovery + // has to be as visible as a failed download; otherwise the only + // symptom is a blank page. + const failure = new Error( + `Failed to instantiate the WebAssembly bundle from ${url}: ` + + `${err.message}. ` + + deliveryGuidance(config), + { cause: err }, + ); + showRuntimeFailure(failure); + throw failure; } if (bar) bar.finish(true); const key = recoveryKey(url); diff --git a/examples/counter/wasm_loader.js b/examples/counter/wasm_loader.js index 191039a2..4911634b 100644 --- a/examples/counter/wasm_loader.js +++ b/examples/counter/wasm_loader.js @@ -79,15 +79,41 @@ : [], negotiated: global.RFW_WASM_NEGOTIATED === true, production: global.RFW_BUILD_MODE === "production", + embedded: global.RFW_WASM_DELIVERY === "embedded", }; } + // deliveryGuidance names the next thing to check when the bundle cannot be + // loaded. The two delivery modes fail for unrelated reasons, and a packaged + // application has no address bar or network panel to investigate with. + function deliveryGuidance(config) { + if (config.embedded) { + return ( + "Check that `rfw build` ran with build.delivery set to embedded in rfw.json, " + + "that build/client/app.wasm exists, and that the native container packaged it " + + "unmodified." + ); + } + return ( + "Check that `rfw build` produced app.wasm.br and app.wasm.gz and that the server " + + "sends Content-Encoding for them." + ); + } + // buildPlan orders the ways this bundle can be fetched, best first. A // negotiated request is preferred because the browser picks the coding it // actually supports; named artifacts follow for static hosting. function buildPlan(url, config) { const trimmed = url.trim(); if (!trimmed) return []; + // An embedded build packages the raw bundle with the application: + // there is no server to negotiate with and no compressed artifact on + // disk. The raw wasm is the whole plan, in production as much as in + // development, because it is a local read rather than the + // multi-megabyte download the production rule exists to prevent. + if (config.embedded) { + return [{ url: trimmed, kind: "embedded" }]; + } const queryIndex = trimmed.indexOf("?"); const base = queryIndex === -1 ? trimmed : trimmed.slice(0, queryIndex); const query = queryIndex === -1 ? "" : trimmed.slice(queryIndex); @@ -133,6 +159,12 @@ if (step.kind === "identity") { return { response, encoded: false }; } + if (step.kind === "embedded") { + // A native asset handler serves the packaged file as it is. Report + // whatever it did send, so the progress bar stays honest on a + // handler that happens to label the response. + return { response, encoded }; + } if (encoding === step.kind) { // The server labelled it, so the browser already decoded it. return { response, encoded: true }; @@ -316,8 +348,7 @@ const error = new Error( `Failed to load the WebAssembly bundle. Tried ${plan .map((step) => step.kind) - .join(", ")}. ${detail}. ` + - "Check that `rfw build` produced app.wasm.br and app.wasm.gz and that the server sends Content-Encoding for them.", + .join(", ")}. ${detail}. ` + deliveryGuidance(config), ); console.error(error); showRuntimeFailure(error); @@ -363,7 +394,17 @@ } catch (err) { if (bar) bar.finish(false); console.error("Failed to instantiate Wasm bundle", err); - throw err; + // A corrupt bundle fails here rather than at fetch time. Recovery + // has to be as visible as a failed download; otherwise the only + // symptom is a blank page. + const failure = new Error( + `Failed to instantiate the WebAssembly bundle from ${url}: ` + + `${err.message}. ` + + deliveryGuidance(config), + { cause: err }, + ); + showRuntimeFailure(failure); + throw failure; } if (bar) bar.finish(true); const key = recoveryKey(url); diff --git a/examples/dashboard/wasm_loader.js b/examples/dashboard/wasm_loader.js index 191039a2..4911634b 100644 --- a/examples/dashboard/wasm_loader.js +++ b/examples/dashboard/wasm_loader.js @@ -79,15 +79,41 @@ : [], negotiated: global.RFW_WASM_NEGOTIATED === true, production: global.RFW_BUILD_MODE === "production", + embedded: global.RFW_WASM_DELIVERY === "embedded", }; } + // deliveryGuidance names the next thing to check when the bundle cannot be + // loaded. The two delivery modes fail for unrelated reasons, and a packaged + // application has no address bar or network panel to investigate with. + function deliveryGuidance(config) { + if (config.embedded) { + return ( + "Check that `rfw build` ran with build.delivery set to embedded in rfw.json, " + + "that build/client/app.wasm exists, and that the native container packaged it " + + "unmodified." + ); + } + return ( + "Check that `rfw build` produced app.wasm.br and app.wasm.gz and that the server " + + "sends Content-Encoding for them." + ); + } + // buildPlan orders the ways this bundle can be fetched, best first. A // negotiated request is preferred because the browser picks the coding it // actually supports; named artifacts follow for static hosting. function buildPlan(url, config) { const trimmed = url.trim(); if (!trimmed) return []; + // An embedded build packages the raw bundle with the application: + // there is no server to negotiate with and no compressed artifact on + // disk. The raw wasm is the whole plan, in production as much as in + // development, because it is a local read rather than the + // multi-megabyte download the production rule exists to prevent. + if (config.embedded) { + return [{ url: trimmed, kind: "embedded" }]; + } const queryIndex = trimmed.indexOf("?"); const base = queryIndex === -1 ? trimmed : trimmed.slice(0, queryIndex); const query = queryIndex === -1 ? "" : trimmed.slice(queryIndex); @@ -133,6 +159,12 @@ if (step.kind === "identity") { return { response, encoded: false }; } + if (step.kind === "embedded") { + // A native asset handler serves the packaged file as it is. Report + // whatever it did send, so the progress bar stays honest on a + // handler that happens to label the response. + return { response, encoded }; + } if (encoding === step.kind) { // The server labelled it, so the browser already decoded it. return { response, encoded: true }; @@ -316,8 +348,7 @@ const error = new Error( `Failed to load the WebAssembly bundle. Tried ${plan .map((step) => step.kind) - .join(", ")}. ${detail}. ` + - "Check that `rfw build` produced app.wasm.br and app.wasm.gz and that the server sends Content-Encoding for them.", + .join(", ")}. ${detail}. ` + deliveryGuidance(config), ); console.error(error); showRuntimeFailure(error); @@ -363,7 +394,17 @@ } catch (err) { if (bar) bar.finish(false); console.error("Failed to instantiate Wasm bundle", err); - throw err; + // A corrupt bundle fails here rather than at fetch time. Recovery + // has to be as visible as a failed download; otherwise the only + // symptom is a blank page. + const failure = new Error( + `Failed to instantiate the WebAssembly bundle from ${url}: ` + + `${err.message}. ` + + deliveryGuidance(config), + { cause: err }, + ); + showRuntimeFailure(failure); + throw failure; } if (bar) bar.finish(true); const key = recoveryKey(url); diff --git a/examples/dynamic-list/wasm_loader.js b/examples/dynamic-list/wasm_loader.js index 191039a2..4911634b 100644 --- a/examples/dynamic-list/wasm_loader.js +++ b/examples/dynamic-list/wasm_loader.js @@ -79,15 +79,41 @@ : [], negotiated: global.RFW_WASM_NEGOTIATED === true, production: global.RFW_BUILD_MODE === "production", + embedded: global.RFW_WASM_DELIVERY === "embedded", }; } + // deliveryGuidance names the next thing to check when the bundle cannot be + // loaded. The two delivery modes fail for unrelated reasons, and a packaged + // application has no address bar or network panel to investigate with. + function deliveryGuidance(config) { + if (config.embedded) { + return ( + "Check that `rfw build` ran with build.delivery set to embedded in rfw.json, " + + "that build/client/app.wasm exists, and that the native container packaged it " + + "unmodified." + ); + } + return ( + "Check that `rfw build` produced app.wasm.br and app.wasm.gz and that the server " + + "sends Content-Encoding for them." + ); + } + // buildPlan orders the ways this bundle can be fetched, best first. A // negotiated request is preferred because the browser picks the coding it // actually supports; named artifacts follow for static hosting. function buildPlan(url, config) { const trimmed = url.trim(); if (!trimmed) return []; + // An embedded build packages the raw bundle with the application: + // there is no server to negotiate with and no compressed artifact on + // disk. The raw wasm is the whole plan, in production as much as in + // development, because it is a local read rather than the + // multi-megabyte download the production rule exists to prevent. + if (config.embedded) { + return [{ url: trimmed, kind: "embedded" }]; + } const queryIndex = trimmed.indexOf("?"); const base = queryIndex === -1 ? trimmed : trimmed.slice(0, queryIndex); const query = queryIndex === -1 ? "" : trimmed.slice(queryIndex); @@ -133,6 +159,12 @@ if (step.kind === "identity") { return { response, encoded: false }; } + if (step.kind === "embedded") { + // A native asset handler serves the packaged file as it is. Report + // whatever it did send, so the progress bar stays honest on a + // handler that happens to label the response. + return { response, encoded }; + } if (encoding === step.kind) { // The server labelled it, so the browser already decoded it. return { response, encoded: true }; @@ -316,8 +348,7 @@ const error = new Error( `Failed to load the WebAssembly bundle. Tried ${plan .map((step) => step.kind) - .join(", ")}. ${detail}. ` + - "Check that `rfw build` produced app.wasm.br and app.wasm.gz and that the server sends Content-Encoding for them.", + .join(", ")}. ${detail}. ` + deliveryGuidance(config), ); console.error(error); showRuntimeFailure(error); @@ -363,7 +394,17 @@ } catch (err) { if (bar) bar.finish(false); console.error("Failed to instantiate Wasm bundle", err); - throw err; + // A corrupt bundle fails here rather than at fetch time. Recovery + // has to be as visible as a failed download; otherwise the only + // symptom is a blank page. + const failure = new Error( + `Failed to instantiate the WebAssembly bundle from ${url}: ` + + `${err.message}. ` + + deliveryGuidance(config), + { cause: err }, + ); + showRuntimeFailure(failure); + throw failure; } if (bar) bar.finish(true); const key = recoveryKey(url); diff --git a/hostclient/capacitor_transport_wasm_test.go b/hostclient/capacitor_transport_wasm_test.go new file mode 100644 index 00000000..ef161b2c --- /dev/null +++ b/hostclient/capacitor_transport_wasm_test.go @@ -0,0 +1,184 @@ +//go:build js && wasm + +package hostclient + +import ( + "context" + "strings" + "testing" + "time" + + js "github.com/rfwlab/rfw/v2/js" +) + +const fakeCapacitorSSCSource = ` +(function () { + window.__fakeRFWSSC = { + connects: [], + sends: [], + closes: [], + closeDelayMs: -1, + terminalCallbacks: 0, + callback: null, + connect: function (options, callback) { + this.connects.push(options); + this.callback = callback; + callback({ type: "open" }); + }, + send: function (options) { this.sends.push(options); }, + close: function (options) { + var self = this; + this.closes.push(options); + if (this.closeDelayMs >= 0) { + setTimeout(function () { + self.terminalCallbacks++; + self.callback({ type: "close", code: 1000, reason: "closed" }); + }, this.closeDelayMs); + } + } + }; + window.Capacitor = { Plugins: { RFWSSC: window.__fakeRFWSSC } }; +})(); +` + +func installFakeCapacitorSSC(t *testing.T) js.Value { + t.Helper() + originalCapacitor := js.Get("Capacitor") + originalTransport := js.Get("RFW_SSC_TRANSPORT") + js.Call("eval", fakeCapacitorSSCSource) + js.Set("RFW_SSC_TRANSPORT", sscTransportCapacitor) + t.Cleanup(func() { + js.Set("Capacitor", originalCapacitor) + js.Set("RFW_SSC_TRANSPORT", originalTransport) + js.Global().Delete("__fakeRFWSSC") + }) + return js.Get("__fakeRFWSSC") +} + +func TestSSCTransportDefaultsToBrowser(t *testing.T) { + original := js.Get("RFW_SSC_TRANSPORT") + js.Global().Delete("RFW_SSC_TRANSPORT") + t.Cleanup(func() { js.Set("RFW_SSC_TRANSPORT", original) }) + + if got := sscTransport(); got != sscTransportBrowser { + t.Fatalf("transport = %q, want browser", got) + } +} + +func TestCapacitorTransportFailsClosedWhenPluginIsMissing(t *testing.T) { + originalCapacitor := js.Get("Capacitor") + originalTransport := js.Get("RFW_SSC_TRANSPORT") + js.Set("Capacitor", js.NewDict().Value) + js.Set("RFW_SSC_TRANSPORT", sscTransportCapacitor) + t.Cleanup(func() { + js.Set("Capacitor", originalCapacitor) + js.Set("RFW_SSC_TRANSPORT", originalTransport) + }) + + _, err := dial(context.Background(), "wss://api.example.com/ws") + if err == nil || !strings.Contains(err.Error(), "plugins are unavailable") { + t.Fatalf("dial error = %v, want missing-plugin failure", err) + } +} + +func TestCapacitorTransportConnectsWritesReadsAndCloses(t *testing.T) { + plugin := installFakeCapacitorSSC(t) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + conn, err := dial(ctx, "wss://api.example.com/ws") + if err != nil { + t.Fatalf("dial: %v", err) + } + if got := plugin.Get("connects").Get("length").Int(); got != 1 { + t.Fatalf("connect calls = %d, want 1", got) + } + connect := plugin.Get("connects").Index(0) + if got := connect.Get("url").String(); got != "wss://api.example.com/ws" { + t.Fatalf("connect url = %q", got) + } + if connect.Get("id").String() == "" { + t.Fatal("connect id is empty") + } + + if err := conn.writeJSON(wireMessage{Component: "ticker", Sequence: 1}); err != nil { + t.Fatalf("write: %v", err) + } + if got := plugin.Get("sends").Get("length").Int(); got != 1 { + t.Fatalf("send calls = %d, want 1", got) + } + if frame := plugin.Get("sends").Index(0).Get("data").String(); !strings.Contains(frame, `"component":"ticker"`) { + t.Fatalf("sent frame = %q", frame) + } + + event := js.NewDict() + event.Set("type", "message") + event.Set("encoding", "text") + event.Set("data", `{"component":"ticker","sequence":1}`) + plugin.Get("callback").Invoke(event.Value) + frame, err := conn.read(ctx) + if err != nil { + t.Fatalf("read: %v", err) + } + if got := string(frame); got != `{"component":"ticker","sequence":1}` { + t.Fatalf("frame = %q", got) + } + + if err := conn.close(); err != nil { + t.Fatalf("close: %v", err) + } + if got := plugin.Get("closes").Get("length").Int(); got != 1 { + t.Fatalf("close calls = %d, want 1", got) + } +} + +func TestCapacitorTransportRejectsMalformedBinaryFrame(t *testing.T) { + plugin := installFakeCapacitorSSC(t) + conn, err := dial(context.Background(), "wss://api.example.com/ws") + if err != nil { + t.Fatalf("dial: %v", err) + } + t.Cleanup(func() { _ = conn.close() }) + + event := js.NewDict() + event.Set("type", "message") + event.Set("encoding", "base64") + event.Set("data", "not base64") + plugin.Get("callback").Invoke(event.Value) + + if _, err := conn.read(context.Background()); err == nil || !strings.Contains(err.Error(), "invalid base64") { + t.Fatalf("read error = %v, want invalid base64", err) + } +} + +func TestCapacitorTransportKeepsCallbackAliveThroughAsyncClose(t *testing.T) { + plugin := installFakeCapacitorSSC(t) + plugin.Set("closeDelayMs", 10) + conn, err := dial(context.Background(), "wss://api.example.com/ws") + if err != nil { + t.Fatalf("dial: %v", err) + } + + if err := conn.close(); err != nil { + t.Fatalf("close: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if _, err := conn.read(ctx); err == nil || !strings.Contains(err.Error(), "code 1000") { + t.Fatalf("read error = %v, want native close", err) + } + if got := plugin.Get("terminalCallbacks").Int(); got != 1 { + t.Fatalf("terminal callbacks = %d, want 1", got) + } +} + +func TestCapacitorTransportDoesNotFallBackForUnknownMode(t *testing.T) { + original := js.Get("RFW_SSC_TRANSPORT") + js.Set("RFW_SSC_TRANSPORT", "native") + t.Cleanup(func() { js.Set("RFW_SSC_TRANSPORT", original) }) + + _, err := dial(context.Background(), "wss://api.example.com/ws") + if err == nil || !strings.Contains(err.Error(), "unsupported SSC transport") { + t.Fatalf("dial error = %v, want unsupported transport", err) + } +} diff --git a/hostclient/transport.go b/hostclient/transport.go index 104adee8..8456dcc2 100644 --- a/hostclient/transport.go +++ b/hostclient/transport.go @@ -35,8 +35,15 @@ var errConnectionClosed = errors.New("hostclient: connection closed") type hostConn struct { socket *js.Socket send func(string) error - messages chan []byte - open chan struct{} + shutdown func() error + release func() + // generation binds the transport to the authenticated client session that + // created it. ResetSession advances the global generation before closing + // the socket, so a late frame from the old native/browser callback cannot + // reach handlers owned by the replacement identity. + generation uint64 + messages chan []byte + open chan struct{} // done is closed once, so every reader observes the end of the connection. // A one-shot error channel would hand the failure to whichever read // happened to be waiting and leave the next one blocked forever. @@ -56,8 +63,9 @@ func newHostConn() *hostConn { } } -// dial opens a connection and waits for the browser handshake to complete. -func dial(ctx context.Context, url string) (*hostConn, error) { +// dialBrowser opens a connection and waits for the browser handshake to +// complete. It remains the default SSC transport for existing applications. +func dialBrowser(ctx context.Context, url string) (*hostConn, error) { c := newHostConn() socket, err := js.OpenSocket(url, js.SocketHandlers{ MaxMessageBytes: int(maxInboundMessageBytes), @@ -74,6 +82,7 @@ func dial(ctx context.Context, url string) (*hostConn, error) { } c.socket = socket c.send = socket.SendText + c.shutdown = func() error { return socket.Close(normalClosure, "connection closed") } select { case <-c.open: @@ -88,6 +97,13 @@ func dial(ctx context.Context, url string) (*hostConn, error) { } func (c *hostConn) deliver(payload []byte) { + if int64(len(payload)) > maxInboundMessageBytes { + c.fail(fmt.Errorf( + "hostclient: inbound message of %d bytes exceeds the %d byte limit", + len(payload), maxInboundMessageBytes, + )) + return + } select { case c.messages <- payload: c.received.Add(1) @@ -155,10 +171,17 @@ func (c *hostConn) writeJSON(payload any) error { } func (c *hostConn) close() error { - if c.socket == nil { - return nil + var err error + if c.shutdown != nil { + err = c.shutdown() + } else if c.socket != nil { + err = c.socket.Close(normalClosure, "connection closed") + } + if c.release != nil { + c.release() + c.release = nil } - return c.socket.Close(normalClosure, "connection closed") + return err } // heartbeat probes liveness with a control message. The browser WebSocket API diff --git a/hostclient/transport_selector.go b/hostclient/transport_selector.go new file mode 100644 index 00000000..64e1a20d --- /dev/null +++ b/hostclient/transport_selector.go @@ -0,0 +1,185 @@ +//go:build js && wasm + +package hostclient + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "strings" + "sync" + "sync/atomic" + "time" + + js "github.com/rfwlab/rfw/v2/js" +) + +const ( + sscTransportBrowser = "browser" + sscTransportCapacitor = "capacitor" + capacitorPluginName = "RFWSSC" +) + +var nativeConnectionSequence atomic.Uint64 + +// dial selects the transport stamped into rfw_config.js. Browser WebSocket is +// the compatibility default. A configured native transport never falls back: +// silently doing so would restore the cookie boundary the native transport was +// selected to avoid. +func dial(ctx context.Context, url string) (*hostConn, error) { + switch sscTransport() { + case sscTransportBrowser: + return dialBrowser(ctx, url) + case sscTransportCapacitor: + return dialCapacitor(ctx, url) + default: + return nil, fmt.Errorf("hostclient: unsupported SSC transport %q", sscTransport()) + } +} + +func sscTransport() string { + configured := js.Get("RFW_SSC_TRANSPORT") + if !configured.Truthy() { + return sscTransportBrowser + } + return strings.TrimSpace(configured.String()) +} + +// dialCapacitor connects through the RFW Capacitor plugin. Authentication +// cookies and the Origin header are owned by the native URLSession; Go/WASM +// receives protocol frames and lifecycle events, never cookie values. +func dialCapacitor(ctx context.Context, url string) (*hostConn, error) { + plugin, err := capacitorSSCPlugin() + if err != nil { + return nil, err + } + + c := newHostConn() + id := fmt.Sprintf("rfw-ssc-%d", nativeConnectionSequence.Add(1)) + var callback js.Func + var releaseOnce sync.Once + releaseCallback := func() { releaseOnce.Do(callback.Release) } + releaseAfterCallback := func() { time.AfterFunc(time.Millisecond, releaseCallback) } + callback = js.SafeFuncOf(func(_ js.Value, args []js.Value) any { + if len(args) > 1 && args[1].Truthy() { + c.fail(errors.New("hostclient: native SSC callback failed")) + releaseAfterCallback() + return nil + } + if len(args) == 0 || !args[0].Truthy() { + c.fail(errors.New("hostclient: native SSC callback returned no event")) + releaseAfterCallback() + return nil + } + event := args[0] + switch event.Get("type").String() { + case "open": + c.openOnce.Do(func() { close(c.open) }) + case "message": + payload, decodeErr := decodeNativeFrame(event) + if decodeErr != nil { + c.fail(decodeErr) + return nil + } + c.deliver(payload) + case "error": + c.fail(nativeEventError(event, "native SSC transport failed")) + releaseAfterCallback() + case "close": + c.fail(fmt.Errorf( + "hostclient: native SSC connection closed with code %d: %s", + event.Get("code").Int(), event.Get("reason").String(), + )) + releaseAfterCallback() + default: + c.fail(errors.New("hostclient: native SSC transport returned an unknown event")) + releaseAfterCallback() + } + return nil + }) + // Native close crosses the Capacitor bridge asynchronously. Keep the Go + // callback alive long enough for the final close/error event; a terminal + // callback releases it sooner through the same once guard. + c.release = func() { time.AfterFunc(time.Second, releaseCallback) } + c.send = func(data string) error { + options := js.NewDict() + options.Set("id", id) + options.Set("data", data) + return callNativePlugin(plugin, "send", options.Value) + } + c.shutdown = func() error { + options := js.NewDict() + options.Set("id", id) + return callNativePlugin(plugin, "close", options.Value) + } + + options := js.NewDict() + options.Set("id", id) + options.Set("url", url) + if err := callNativePlugin(plugin, "connect", options.Value, callback); err != nil { + _ = c.close() + return nil, err + } + + select { + case <-c.open: + return c, nil + case <-c.done: + _ = c.close() + return nil, c.err() + case <-ctx.Done(): + _ = c.close() + return nil, ctx.Err() + } +} + +func capacitorSSCPlugin() (js.Value, error) { + capacitor := js.Get("Capacitor") + if !capacitor.Truthy() { + return js.Undefined(), errors.New("hostclient: Capacitor is unavailable for native SSC transport") + } + plugins := capacitor.Get("Plugins") + if !plugins.Truthy() { + return js.Undefined(), errors.New("hostclient: Capacitor plugins are unavailable for native SSC transport") + } + plugin := plugins.Get(capacitorPluginName) + if !plugin.Truthy() { + return js.Undefined(), errors.New("hostclient: RFWSSC Capacitor plugin is not installed") + } + return plugin, nil +} + +func decodeNativeFrame(event js.Value) ([]byte, error) { + data := event.Get("data").String() + switch event.Get("encoding").String() { + case "text": + return []byte(data), nil + case "base64": + payload, err := base64.StdEncoding.DecodeString(data) + if err != nil { + return nil, errors.New("hostclient: native SSC returned invalid base64") + } + return payload, nil + default: + return nil, errors.New("hostclient: native SSC returned an unknown frame encoding") + } +} + +func nativeEventError(event js.Value, fallback string) error { + message := strings.TrimSpace(event.Get("message").String()) + if message == "" { + message = fallback + } + return errors.New("hostclient: " + message) +} + +func callNativePlugin(plugin js.Value, method string, args ...any) (err error) { + defer func() { + if recovered := recover(); recovered != nil { + err = fmt.Errorf("hostclient: native SSC %s call failed", method) + } + }() + plugin.Call(method, args...) + return nil +} diff --git a/mobile/capacitor-ssc/.gitignore b/mobile/capacitor-ssc/.gitignore new file mode 100644 index 00000000..2d9f16e2 --- /dev/null +++ b/mobile/capacitor-ssc/.gitignore @@ -0,0 +1,2 @@ +.build/ +.swiftpm/ diff --git a/mobile/capacitor-ssc/Package.resolved b/mobile/capacitor-ssc/Package.resolved new file mode 100644 index 00000000..6d0e615d --- /dev/null +++ b/mobile/capacitor-ssc/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "capacitor-swift-pm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ionic-team/capacitor-swift-pm.git", + "state" : { + "revision" : "6afa7424fd2fcd8ca1e577478e8a00af284b7e82", + "version" : "8.5.1" + } + } + ], + "version" : 2 +} diff --git a/mobile/capacitor-ssc/Package.swift b/mobile/capacitor-ssc/Package.swift new file mode 100644 index 00000000..f8123b0d --- /dev/null +++ b/mobile/capacitor-ssc/Package.swift @@ -0,0 +1,29 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "RfwlabCapacitorSsc", + platforms: [.iOS(.v15)], + products: [ + .library(name: "RfwlabCapacitorSsc", targets: ["RFWSSCPlugin"]) + ], + dependencies: [ + .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0") + ], + targets: [ + .target( + name: "RFWSSCPlugin", + dependencies: [ + .product(name: "Capacitor", package: "capacitor-swift-pm"), + .product(name: "Cordova", package: "capacitor-swift-pm") + ], + path: "ios/Sources/RFWSSCPlugin" + ), + .testTarget( + name: "RFWSSCPluginTests", + dependencies: ["RFWSSCPlugin"], + path: "ios/Tests/RFWSSCPluginTests" + ) + ] +) diff --git a/mobile/capacitor-ssc/README.md b/mobile/capacitor-ssc/README.md new file mode 100644 index 00000000..bfa1c04b --- /dev/null +++ b/mobile/capacitor-ssc/README.md @@ -0,0 +1,57 @@ +# RFW Capacitor SSC transport (local preview) + +This unpublished package supplies the iOS-native SSC transport selected by +`build.sscTransport: "capacitor"`. It is intentionally local while the physical +device and security gates are being completed. + +Install it from this checkout into the Capacitor wrapper, then synchronize the +native project: + +```bash +npm install --save /absolute/path/to/rfw/mobile/capacitor-ssc +npx cap sync ios +``` + +Run the iOS package tests with a full Xcode installation and at least one +available simulator: + +```bash +npm run verify:ios +``` + +Set `RFW_IOS_SIMULATOR_ID` to select a specific simulator; otherwise the script +uses the first available iOS simulator reported by Xcode. + +Configure an exact native endpoint allowlist. The plugin fails closed when the +allowlist is absent, when the endpoint is not WSS, or when the requested origin +does not match exactly: + +```json +{ + "plugins": { + "RFWSSC": { + "allowedOrigins": ["wss://api.example.com"], + "origin": "capacitor://localhost", + "outboundQueueSize": 256, + "maxMessageBytes": 8388608, + "connectTimeoutMilliseconds": 10000 + } + } +} +``` + +The plugin uses `HTTPCookieStorage.shared`, the same native cookie jar used by +Capacitor HTTP on iOS. Cookie values are never returned over the JavaScript +bridge. Only SSC frames and connection lifecycle events cross the bridge. + +Changing the authenticated user or exact host auth-session ID is a connection +ownership boundary. After the authoritative login response has installed the +new cookie, unmount the previous user's protected owners and call +`hostclient.ResetSession()` before mounting the replacement identity. The reset +closes this native socket and discards the old resume/outbox state; allowing the +old connection to survive would keep receiving data for its original user until +the next background or network reconnect. + +This preview contains no Android implementation yet. The Go/WASM selector and +bridge event contract are platform-neutral; Android remains a separate native +implementation and physical-device gate. diff --git a/mobile/capacitor-ssc/RFWCapacitorSSC.podspec b/mobile/capacitor-ssc/RFWCapacitorSSC.podspec new file mode 100644 index 00000000..6bc308a3 --- /dev/null +++ b/mobile/capacitor-ssc/RFWCapacitorSSC.podspec @@ -0,0 +1,13 @@ +Pod::Spec.new do |s| + s.name = 'RFWCapacitorSSC' + s.version = '0.0.0-dev' + s.summary = 'Native iOS SSC transport for RFW Capacitor applications.' + s.license = { :type => 'AGPL-3.0-only' } + s.homepage = 'https://github.com/rfwlab/rfw' + s.author = 'RFW contributors' + s.source = { :git => 'https://github.com/rfwlab/rfw.git', :tag => s.version.to_s } + s.source_files = 'ios/Sources/RFWSSCPlugin/**/*.{swift,h,m,c,cc,mm,cpp}' + s.ios.deployment_target = '15.0' + s.dependency 'Capacitor', '>= 8.0', '< 9.0' + s.swift_version = '5.9' +end diff --git a/mobile/capacitor-ssc/ios/Sources/RFWSSCPlugin/BoundedOutboundQueue.swift b/mobile/capacitor-ssc/ios/Sources/RFWSSCPlugin/BoundedOutboundQueue.swift new file mode 100644 index 00000000..6b892050 --- /dev/null +++ b/mobile/capacitor-ssc/ios/Sources/RFWSSCPlugin/BoundedOutboundQueue.swift @@ -0,0 +1,33 @@ +import Foundation + +struct BoundedOutboundQueue { + private let capacity: Int + private var pending: [String] = [] + private(set) var sending = false + + init(capacity: Int) { + self.capacity = capacity + } + + mutating func enqueue(_ value: String) -> Bool { + let occupied = pending.count + (sending ? 1 : 0) + guard occupied < capacity else { return false } + pending.append(value) + return true + } + + mutating func next() -> String? { + guard !sending, !pending.isEmpty else { return nil } + sending = true + return pending.removeFirst() + } + + mutating func complete() { + sending = false + } + + mutating func removeAll() { + pending.removeAll(keepingCapacity: false) + sending = false + } +} diff --git a/mobile/capacitor-ssc/ios/Sources/RFWSSCPlugin/NativeSSCConnection.swift b/mobile/capacitor-ssc/ios/Sources/RFWSSCPlugin/NativeSSCConnection.swift new file mode 100644 index 00000000..c3d29a50 --- /dev/null +++ b/mobile/capacitor-ssc/ios/Sources/RFWSSCPlugin/NativeSSCConnection.swift @@ -0,0 +1,229 @@ +import Capacitor +import Foundation + +final class NativeSSCConnection: NSObject, URLSessionWebSocketDelegate { + private let identifier: String + private let request: URLRequest + private let callback: CAPPluginCall + private let maxMessageBytes: Int + private let onFinish: (String) -> Void + private let stateQueue: DispatchQueue + + private var session: URLSession? + private var task: URLSessionWebSocketTask? + private var outbound: BoundedOutboundQueue + private var closed = false + + init( + identifier: String, + request: URLRequest, + callback: CAPPluginCall, + outboundQueueSize: Int, + maxMessageBytes: Int, + onFinish: @escaping (String) -> Void + ) { + self.identifier = identifier + self.request = request + self.callback = callback + self.maxMessageBytes = maxMessageBytes + self.onFinish = onFinish + self.stateQueue = DispatchQueue(label: "dev.rfw.capacitor.ssc.\(identifier)") + self.outbound = BoundedOutboundQueue(capacity: outboundQueueSize) + super.init() + } + + func start() { + stateQueue.async { [weak self] in + guard let self, !self.closed else { return } + let configuration = URLSessionConfiguration.default + configuration.httpCookieStorage = HTTPCookieStorage.shared + configuration.httpShouldSetCookies = true + configuration.urlCache = nil + let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil) + let task = session.webSocketTask(with: self.request) + self.session = session + self.task = task + task.resume() + self.receiveNext() + } + } + + func enqueue(_ text: String) { + stateQueue.async { [weak self] in + guard let self, !self.closed else { return } + guard self.outbound.enqueue(text) else { + self.finish( + event: [ + "type": "error", + "message": "native SSC outbound queue overflow" + ], + cancelCode: .policyViolation, + cancelReason: "outbound queue overflow" + ) + return + } + self.drainOutbound() + } + } + + func close(code: URLSessionWebSocketTask.CloseCode = .normalClosure, reason: String = "connection closed") { + stateQueue.async { [weak self] in + self?.finish( + event: ["type": "close", "code": code.rawValue, "reason": reason], + cancelCode: code, + cancelReason: reason + ) + } + } + + private func drainOutbound() { + guard !closed, let task, let text = outbound.next() else { return } + task.send(.string(text)) { [weak self] error in + self?.stateQueue.async { + guard let self, !self.closed else { return } + self.outbound.complete() + if let error { + self.finish( + event: ["type": "error", "message": "native SSC write failed"], + cancelCode: .goingAway, + cancelReason: error.localizedDescription + ) + return + } + self.drainOutbound() + } + } + } + + private func receiveNext() { + guard !closed, let task else { return } + task.receive { [weak self] result in + self?.stateQueue.async { + guard let self, !self.closed else { return } + switch result { + case .success(let message): + self.receive(message) + self.receiveNext() + case .failure: + self.finish( + event: ["type": "error", "message": "native SSC read failed"], + cancelCode: .goingAway, + cancelReason: "read failed" + ) + } + } + } + } + + private func receive(_ message: URLSessionWebSocketTask.Message) { + let event: JSObject + let byteCount: Int + switch message { + case .string(let text): + byteCount = text.lengthOfBytes(using: .utf8) + event = ["type": "message", "encoding": "text", "data": text] + case .data(let data): + byteCount = data.count + event = ["type": "message", "encoding": "base64", "data": data.base64EncodedString()] + @unknown default: + finish( + event: ["type": "error", "message": "native SSC returned an unsupported frame"], + cancelCode: .unsupportedData, + cancelReason: "unsupported frame" + ) + return + } + + guard byteCount <= maxMessageBytes else { + finish( + event: ["type": "error", "message": "native SSC inbound frame exceeds maxMessageBytes"], + cancelCode: .messageTooBig, + cancelReason: "message too large" + ) + return + } + callback.resolve(event) + } + + private func finish( + event: JSObject, + cancelCode: URLSessionWebSocketTask.CloseCode, + cancelReason: String + ) { + guard !closed else { return } + closed = true + outbound.removeAll() + task?.cancel(with: cancelCode, reason: cancelReason.data(using: .utf8)) + task = nil + session?.invalidateAndCancel() + session = nil + callback.keepAlive = false + callback.resolve(event) + onFinish(identifier) + } + + func urlSession( + _ session: URLSession, + webSocketTask: URLSessionWebSocketTask, + didOpenWithProtocol protocol: String? + ) { + stateQueue.async { [weak self] in + guard let self, !self.closed, webSocketTask === self.task else { return } + self.callback.resolve(["type": "open"]) + } + } + + func urlSession( + _ session: URLSession, + webSocketTask: URLSessionWebSocketTask, + didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, + reason: Data? + ) { + let text = reason.flatMap { String(data: $0, encoding: .utf8) } ?? "" + stateQueue.async { [weak self] in + guard let self, !self.closed, webSocketTask === self.task else { return } + self.finish( + event: ["type": "close", "code": closeCode.rawValue, "reason": text], + cancelCode: closeCode, + cancelReason: text + ) + } + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { + guard error != nil else { return } + stateQueue.async { [weak self] in + guard let self, !self.closed, task === self.task else { return } + self.finish( + event: ["type": "error", "message": "native SSC connection failed"], + cancelCode: .goingAway, + cancelReason: "connection failed" + ) + } + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + // A redirect could move an authenticated native handshake outside the + // exact allowlist after validation. WebSocket endpoints are therefore + // non-redirecting; the application must configure the canonical URL. + completionHandler(nil) + stateQueue.async { [weak self] in + guard let self, !self.closed, task === self.task else { return } + self.finish( + event: ["type": "error", "message": "native SSC redirect denied"], + cancelCode: .policyViolation, + cancelReason: "redirect denied" + ) + } + } +} diff --git a/mobile/capacitor-ssc/ios/Sources/RFWSSCPlugin/RFWSSCPlugin.swift b/mobile/capacitor-ssc/ios/Sources/RFWSSCPlugin/RFWSSCPlugin.swift new file mode 100644 index 00000000..fa9a74a4 --- /dev/null +++ b/mobile/capacitor-ssc/ios/Sources/RFWSSCPlugin/RFWSSCPlugin.swift @@ -0,0 +1,134 @@ +import Capacitor +import Foundation +import UIKit + +@objc(RFWSSCPlugin) +public final class RFWSSCPlugin: CAPPlugin, CAPBridgedPlugin { + public let identifier = "RFWSSCPlugin" + public let jsName = "RFWSSC" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "connect", returnType: CAPPluginReturnCallback), + CAPPluginMethod(name: "send", returnType: CAPPluginReturnNone), + CAPPluginMethod(name: "close", returnType: CAPPluginReturnNone) + ] + + private let connectionsLock = NSLock() + private var connections: [String: NativeSSCConnection] = [:] + private var observers: [NSObjectProtocol] = [] + private var applicationActive = true + + public override func load() { + setApplicationActive(UIApplication.shared.applicationState == .active) + observers.append( + NotificationCenter.default.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.setApplicationActive(false) + self?.closeAll(reason: "app backgrounded") + } + ) + observers.append( + NotificationCenter.default.addObserver( + forName: UIApplication.didBecomeActiveNotification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.setApplicationActive(true) + } + ) + } + + deinit { + for observer in observers { + NotificationCenter.default.removeObserver(observer) + } + closeAll(reason: "plugin released") + } + + @objc func connect(_ call: CAPPluginCall) { + guard + let id = call.getString("id"), !id.isEmpty, + let endpoint = call.getString("url"), !endpoint.isEmpty + else { + call.reject("RFWSSC connect requires non-empty id and url") + return + } + + do { + let configuration = try SSCPluginConfiguration(pluginConfig: getConfig()) + let request = try configuration.request(endpoint: endpoint) + call.keepAlive = true + let connection = NativeSSCConnection( + identifier: id, + request: request, + callback: call, + outboundQueueSize: configuration.outboundQueueSize, + maxMessageBytes: configuration.maxMessageBytes + ) { [weak self] finishedID in + self?.removeConnection(finishedID) + } + + connectionsLock.lock() + let duplicate = connections[id] != nil + let inactive = !applicationActive + if !duplicate && !inactive { + connections[id] = connection + } + connectionsLock.unlock() + + guard !inactive else { + call.keepAlive = false + call.reject("RFWSSC is unavailable while the application is inactive") + return + } + guard !duplicate else { + call.keepAlive = false + call.reject("RFWSSC connection id already exists") + return + } + connection.start() + } catch { + call.keepAlive = false + call.reject(error.localizedDescription) + } + } + + @objc func send(_ call: CAPPluginCall) { + guard let id = call.getString("id"), let data = call.getString("data") else { return } + connection(id)?.enqueue(data) + } + + @objc func close(_ call: CAPPluginCall) { + guard let id = call.getString("id") else { return } + connection(id)?.close() + } + + private func connection(_ id: String) -> NativeSSCConnection? { + connectionsLock.lock() + defer { connectionsLock.unlock() } + return connections[id] + } + + private func removeConnection(_ id: String) { + connectionsLock.lock() + connections.removeValue(forKey: id) + connectionsLock.unlock() + } + + private func setApplicationActive(_ active: Bool) { + connectionsLock.lock() + applicationActive = active + connectionsLock.unlock() + } + + private func closeAll(reason: String) { + connectionsLock.lock() + let active = Array(connections.values) + connectionsLock.unlock() + for connection in active { + connection.close(code: .goingAway, reason: reason) + } + } +} diff --git a/mobile/capacitor-ssc/ios/Sources/RFWSSCPlugin/SSCPluginConfiguration.swift b/mobile/capacitor-ssc/ios/Sources/RFWSSCPlugin/SSCPluginConfiguration.swift new file mode 100644 index 00000000..d1bae30e --- /dev/null +++ b/mobile/capacitor-ssc/ios/Sources/RFWSSCPlugin/SSCPluginConfiguration.swift @@ -0,0 +1,173 @@ +import Capacitor +import Foundation + +enum SSCConfigurationError: LocalizedError, Equatable { + case missingAllowedOrigins + case invalidAllowedOrigin(String) + case invalidOriginHeader + case invalidEndpoint + case endpointNotAllowed + case insecureEndpoint + case invalidOutboundQueueSize + case invalidMessageLimit + + var errorDescription: String? { + switch self { + case .missingAllowedOrigins: + return "RFWSSC.allowedOrigins must contain at least one exact WSS origin" + case .invalidAllowedOrigin: + return "RFWSSC.allowedOrigins contains an invalid origin" + case .invalidOriginHeader: + return "RFWSSC.origin must be an origin without credentials, path, query, or fragment" + case .invalidEndpoint: + return "RFWSSC received an invalid WebSocket endpoint" + case .endpointNotAllowed: + return "RFWSSC endpoint is not in allowedOrigins" + case .insecureEndpoint: + return "RFWSSC requires WSS except for explicitly enabled loopback development" + case .invalidOutboundQueueSize: + return "RFWSSC.outboundQueueSize must be between 1 and 4096" + case .invalidMessageLimit: + return "RFWSSC.maxMessageBytes must be between 1024 and 16777216" + } + } +} + +struct SSCPluginConfiguration { + static let defaultOutboundQueueSize = 256 + static let defaultMaxMessageBytes = 8 * 1024 * 1024 + static let defaultConnectTimeoutMilliseconds = 10_000 + + let allowedOrigins: Set + let originHeader: String + let outboundQueueSize: Int + let maxMessageBytes: Int + let connectTimeout: TimeInterval + let allowInsecureLocalhost: Bool + + init(pluginConfig: PluginConfig) throws { + let configuredOrigins = pluginConfig.getArray("allowedOrigins")?.compactMap { $0 as? String } ?? [] + try self.init( + allowedOrigins: configuredOrigins, + originHeader: pluginConfig.getString("origin", "capacitor://localhost") ?? "capacitor://localhost", + outboundQueueSize: pluginConfig.getInt("outboundQueueSize", Self.defaultOutboundQueueSize), + maxMessageBytes: pluginConfig.getInt("maxMessageBytes", Self.defaultMaxMessageBytes), + connectTimeoutMilliseconds: pluginConfig.getInt( + "connectTimeoutMilliseconds", + Self.defaultConnectTimeoutMilliseconds + ), + allowInsecureLocalhost: pluginConfig.getBoolean("allowInsecureLocalhost", false) + ) + } + + init( + allowedOrigins: [String], + originHeader: String = "capacitor://localhost", + outboundQueueSize: Int = Self.defaultOutboundQueueSize, + maxMessageBytes: Int = Self.defaultMaxMessageBytes, + connectTimeoutMilliseconds: Int = Self.defaultConnectTimeoutMilliseconds, + allowInsecureLocalhost: Bool = false + ) throws { + guard !allowedOrigins.isEmpty else { + throw SSCConfigurationError.missingAllowedOrigins + } + var normalized = Set() + for candidate in allowedOrigins { + guard let origin = Self.normalizedWebSocketOrigin(candidate) else { + throw SSCConfigurationError.invalidAllowedOrigin(candidate) + } + normalized.insert(origin) + } + guard Self.isValidOriginHeader(originHeader) else { + throw SSCConfigurationError.invalidOriginHeader + } + guard (1...4096).contains(outboundQueueSize) else { + throw SSCConfigurationError.invalidOutboundQueueSize + } + guard (1024...(16 * 1024 * 1024)).contains(maxMessageBytes) else { + throw SSCConfigurationError.invalidMessageLimit + } + + self.allowedOrigins = normalized + self.originHeader = originHeader + self.outboundQueueSize = outboundQueueSize + self.maxMessageBytes = maxMessageBytes + self.connectTimeout = TimeInterval(max(connectTimeoutMilliseconds, 1)) / 1000 + self.allowInsecureLocalhost = allowInsecureLocalhost + } + + func request(endpoint: String) throws -> URLRequest { + guard + let components = URLComponents(string: endpoint), + components.user == nil, + components.password == nil, + components.fragment == nil, + let scheme = components.scheme?.lowercased(), + let host = components.host?.lowercased(), + let url = components.url + else { + throw SSCConfigurationError.invalidEndpoint + } + + if scheme != "wss" { + guard scheme == "ws", allowInsecureLocalhost, Self.isLoopback(host) else { + throw SSCConfigurationError.insecureEndpoint + } + } + guard let origin = Self.normalizedWebSocketOrigin(from: components), allowedOrigins.contains(origin) else { + throw SSCConfigurationError.endpointNotAllowed + } + + var request = URLRequest(url: url) + request.timeoutInterval = connectTimeout + request.setValue(originHeader, forHTTPHeaderField: "Origin") + return request + } + + private static func normalizedWebSocketOrigin(_ raw: String) -> String? { + guard + let components = URLComponents(string: raw), + components.user == nil, + components.password == nil, + components.query == nil, + components.fragment == nil, + components.path.isEmpty || components.path == "/" + else { + return nil + } + return normalizedWebSocketOrigin(from: components) + } + + private static func normalizedWebSocketOrigin(from components: URLComponents) -> String? { + guard + let scheme = components.scheme?.lowercased(), + scheme == "ws" || scheme == "wss", + let host = components.host?.lowercased() + else { + return nil + } + let port = components.port.map { ":\($0)" } ?? "" + return "\(scheme)://\(host)\(port)" + } + + private static func isValidOriginHeader(_ raw: String) -> Bool { + guard + let components = URLComponents(string: raw), + components.user == nil, + components.password == nil, + components.query == nil, + components.fragment == nil, + components.path.isEmpty, + let scheme = components.scheme?.lowercased(), + ["capacitor", "https", "http"].contains(scheme), + components.host != nil + else { + return false + } + return true + } + + private static func isLoopback(_ host: String) -> Bool { + host == "localhost" || host == "127.0.0.1" || host == "::1" + } +} diff --git a/mobile/capacitor-ssc/ios/Tests/RFWSSCPluginTests/BoundedOutboundQueueTests.swift b/mobile/capacitor-ssc/ios/Tests/RFWSSCPluginTests/BoundedOutboundQueueTests.swift new file mode 100644 index 00000000..eb725dd5 --- /dev/null +++ b/mobile/capacitor-ssc/ios/Tests/RFWSSCPluginTests/BoundedOutboundQueueTests.swift @@ -0,0 +1,30 @@ +import XCTest +@testable import RFWSSCPlugin + +final class BoundedOutboundQueueTests: XCTestCase { + func testCapacityIncludesTheInflightFrameAndPreservesOrder() { + var queue = BoundedOutboundQueue(capacity: 2) + + XCTAssertTrue(queue.enqueue("one")) + XCTAssertEqual(queue.next(), "one") + XCTAssertTrue(queue.enqueue("two")) + XCTAssertFalse(queue.enqueue("three")) + + queue.complete() + XCTAssertEqual(queue.next(), "two") + queue.complete() + XCTAssertNil(queue.next()) + } + + func testRemoveAllResetsInflightAndPendingState() { + var queue = BoundedOutboundQueue(capacity: 1) + XCTAssertTrue(queue.enqueue("one")) + XCTAssertEqual(queue.next(), "one") + + queue.removeAll() + + XCTAssertFalse(queue.sending) + XCTAssertTrue(queue.enqueue("two")) + XCTAssertEqual(queue.next(), "two") + } +} diff --git a/mobile/capacitor-ssc/ios/Tests/RFWSSCPluginTests/SSCPluginConfigurationTests.swift b/mobile/capacitor-ssc/ios/Tests/RFWSSCPluginTests/SSCPluginConfigurationTests.swift new file mode 100644 index 00000000..59fa1aa5 --- /dev/null +++ b/mobile/capacitor-ssc/ios/Tests/RFWSSCPluginTests/SSCPluginConfigurationTests.swift @@ -0,0 +1,98 @@ +import XCTest +@testable import RFWSSCPlugin + +final class SSCPluginConfigurationTests: XCTestCase { + func testBuildsAllowlistedWSSRequestWithNativeOrigin() throws { + let configuration = try SSCPluginConfiguration( + allowedOrigins: ["wss://api.example.com"], + originHeader: "capacitor://localhost" + ) + + let request = try configuration.request(endpoint: "wss://api.example.com/ssc?tenant=one") + + XCTAssertEqual(request.url?.absoluteString, "wss://api.example.com/ssc?tenant=one") + XCTAssertEqual(request.value(forHTTPHeaderField: "Origin"), "capacitor://localhost") + } + + func testRejectsEndpointOutsideExactOriginAllowlist() throws { + let configuration = try SSCPluginConfiguration( + allowedOrigins: ["wss://api.example.com"] + ) + + XCTAssertThrowsError(try configuration.request(endpoint: "wss://evil.example/ssc")) { error in + XCTAssertEqual(error as? SSCConfigurationError, .endpointNotAllowed) + } + XCTAssertThrowsError(try configuration.request(endpoint: "wss://api.example.com.evil.invalid/ssc")) { error in + XCTAssertEqual(error as? SSCConfigurationError, .endpointNotAllowed) + } + } + + func testRejectsCredentialsAndInsecureRemoteEndpoints() throws { + let configuration = try SSCPluginConfiguration( + allowedOrigins: ["wss://api.example.com", "ws://api.example.com"] + ) + + XCTAssertThrowsError(try configuration.request(endpoint: "wss://user:pass@api.example.com/ssc")) { error in + XCTAssertEqual(error as? SSCConfigurationError, .invalidEndpoint) + } + XCTAssertThrowsError(try configuration.request(endpoint: "ws://api.example.com/ssc")) { error in + XCTAssertEqual(error as? SSCConfigurationError, .insecureEndpoint) + } + } + + func testInsecureLoopbackRequiresExplicitOptIn() throws { + let denied = try SSCPluginConfiguration(allowedOrigins: ["ws://127.0.0.1:8080"]) + XCTAssertThrowsError(try denied.request(endpoint: "ws://127.0.0.1:8080/ws")) { error in + XCTAssertEqual(error as? SSCConfigurationError, .insecureEndpoint) + } + + let allowed = try SSCPluginConfiguration( + allowedOrigins: ["ws://127.0.0.1:8080"], + allowInsecureLocalhost: true + ) + XCTAssertNoThrow(try allowed.request(endpoint: "ws://127.0.0.1:8080/ws")) + } + + func testRequiresAWellFormedNativeConfiguration() { + XCTAssertThrowsError(try SSCPluginConfiguration(allowedOrigins: [])) { error in + XCTAssertEqual(error as? SSCConfigurationError, .missingAllowedOrigins) + } + XCTAssertThrowsError( + try SSCPluginConfiguration( + allowedOrigins: ["wss://api.example.com/path"] + ) + ) { error in + XCTAssertEqual( + error as? SSCConfigurationError, + .invalidAllowedOrigin("wss://api.example.com/path") + ) + } + XCTAssertThrowsError( + try SSCPluginConfiguration( + allowedOrigins: ["wss://api.example.com"], + originHeader: "capacitor://localhost/path" + ) + ) { error in + XCTAssertEqual(error as? SSCConfigurationError, .invalidOriginHeader) + } + } + + func testRejectsUnsafeQueueAndFrameLimits() { + XCTAssertThrowsError( + try SSCPluginConfiguration( + allowedOrigins: ["wss://api.example.com"], + outboundQueueSize: 0 + ) + ) { error in + XCTAssertEqual(error as? SSCConfigurationError, .invalidOutboundQueueSize) + } + XCTAssertThrowsError( + try SSCPluginConfiguration( + allowedOrigins: ["wss://api.example.com"], + maxMessageBytes: 17 * 1024 * 1024 + ) + ) { error in + XCTAssertEqual(error as? SSCConfigurationError, .invalidMessageLimit) + } + } +} diff --git a/mobile/capacitor-ssc/package.json b/mobile/capacitor-ssc/package.json new file mode 100644 index 00000000..da7b2dea --- /dev/null +++ b/mobile/capacitor-ssc/package.json @@ -0,0 +1,26 @@ +{ + "name": "@rfwlab/capacitor-ssc", + "version": "0.0.0-dev", + "private": true, + "license": "AGPL-3.0-only", + "description": "Native iOS SSC transport for RFW Capacitor applications", + "files": [ + "ios/Sources", + "ios/Tests", + "scripts", + "Package.swift", + "RFWCapacitorSSC.podspec", + "README.md" + ], + "peerDependencies": { + "@capacitor/core": ">=8.0.0 <9.0.0" + }, + "capacitor": { + "ios": { + "src": "ios" + } + }, + "scripts": { + "verify:ios": "bash scripts/verify-ios.sh" + } +} diff --git a/mobile/capacitor-ssc/scripts/verify-ios.sh b/mobile/capacitor-ssc/scripts/verify-ios.sh new file mode 100755 index 00000000..2479b03c --- /dev/null +++ b/mobile/capacitor-ssc/scripts/verify-ios.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ -z "${DEVELOPER_DIR:-}" && -d /Applications/Xcode.app/Contents/Developer ]]; then + export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer +fi + +device_id="${RFW_IOS_SIMULATOR_ID:-}" +if [[ -z "$device_id" ]]; then + device_id="$({ + xcrun simctl list devices available + } | sed -nE 's/.*\(([0-9A-Fa-f-]{36})\) \((Booted|Shutdown)\).*/\1/p' | head -n 1)" +fi +if [[ -z "$device_id" ]]; then + echo "No available iOS Simulator found; install one in Xcode or set RFW_IOS_SIMULATOR_ID." >&2 + exit 1 +fi + +exec xcodebuild \ + -scheme RfwlabCapacitorSsc \ + -destination "platform=iOS Simulator,id=$device_id" \ + test From bd39268e5dbabd9853c9ec94900a9a5de74281ca Mon Sep 17 00:00:00 2001 From: Pietro di Caprio Date: Tue, 1 Sep 2026 14:38:37 +0200 Subject: [PATCH 2/3] fix(hostclient): reset SSC across identity changes Close and generation-fence the previous transport, clear resume and delivery state, fail pending calls, and preserve only registrations owned by components that remain mounted. --- CHANGELOG.md | 21 ++++ hostclient/runtime_foundation.go | 100 ++++++++++++++---- hostclient/runtime_wasm_test.go | 170 ++++++++++++++++++++++++++++++- 3 files changed, 268 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72775a0b..eb42870c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,27 @@ version and include migration notes. a host delivery uses to make its ownership check and its write one step against a concurrent revocation. `SetFromHost` is unchanged and is the ungated form of the same write. +- `hostclient.ResetSession` for authoritative authenticated-identity changes. + It closes the active SSC transport, generation-fences late frames, clears the + previous session's resume token, sequence/replay state, queued messages and + pending calls, while preserving registrations owned by components that are + still mounted. User-owned scopes must be unmounted before reset; the + connection loop then initializes the remaining live owners against the new + cookie identity. +- `build.delivery` in `rfw.json`. The default, `"network"`, is the existing + contract: compressed artifacts and a loader that picks one. `"embedded"` is + for a client packaged with the application, the way a native container ships + it. An embedded production build keeps the optimized raw `app.wasm`, writes no + compressed artifact and removes any an earlier build left behind, and the + loader loads that one file rather than demanding a content coding a local + asset handler cannot provide. It stays a production build: `RFW_BUILD_MODE` + remains `production` and a hosted build still refuses to fall back to the raw + bundle. The setting is orthogonal to `build.type`, so an embedded `"ssc"` + application still links the host client and still reaches its host over WSS. + An unknown value fails the build instead of being ignored. A project that + ships its own `wasm_loader.js` keeps that copy, and a copy predating this + setting ignores `RFW_WASM_DELIVERY`: update the override or delete it so the + build writes the framework loader, before opting into `"embedded"`. ### Changed diff --git a/hostclient/runtime_foundation.go b/hostclient/runtime_foundation.go index 8656cd11..54fbd91e 100644 --- a/hostclient/runtime_foundation.go +++ b/hostclient/runtime_foundation.go @@ -60,20 +60,22 @@ type hostSetter interface{ SetFromHost(any) } type hostWriteBarrier interface{ HostWriteBarrier() } var ( - conn *hostConn - bindings = map[string]componentBinding{} - bindingTokens = map[string]uint64{} - once sync.Once - mu sync.RWMutex - pending []message - outbox = map[uint64]message{} - handlers = map[string]func(map[string]any){} - handlerTokens = map[string]uint64{} - dedup = map[string]struct{}{} - debug bool - cb *fnres.CircuitBreaker - sendCache *fncaching.InMemoryCache[string] - hydrateCB *fnres.CircuitBreaker + conn *hostConn + bindings = map[string]componentBinding{} + bindingTokens = map[string]uint64{} + once sync.Once + mu sync.RWMutex + pending []message + outbox = map[uint64]message{} + handlers = map[string]func(map[string]any){} + handlerTokens = map[string]uint64{} + dedup = map[string]struct{}{} + debug bool + cb *fnres.CircuitBreaker + sendCache *fncaching.InMemoryCache[string] + hydrateCB *fnres.CircuitBreaker + lifecycleMu sync.RWMutex + connectionGeneration atomic.Uint64 sessionMu sync.RWMutex sessionID string @@ -137,10 +139,13 @@ type wireMessage struct { type messageWriter func(context.Context, *hostConn, wireMessage) error type actionReply struct { - payload any - err *ActionError + payload any + err *ActionError + resetErr error } +var ErrSessionReset = errors.New("hostclient: session reset") + func decodeInitSnapshotPayload(raw any) *initSnapshotPayload { if raw == nil { return nil @@ -262,6 +267,7 @@ func connectionLoop() { err := fnres.Retry(context.Background(), func() error { return cb.Execute(func() error { + generation := connectionGeneration.Load() if debug { log.Printf("hostclient: dialing %s", url) } @@ -271,6 +277,13 @@ func connectionLoop() { if derr != nil { return derr } + c.generation = generation + lifecycleMu.RLock() + if generation != connectionGeneration.Load() { + lifecycleMu.RUnlock() + _ = c.close() + return nil + } sendMu.Lock() mu.Lock() @@ -323,6 +336,7 @@ func connectionLoop() { sendMessageUnlocked(c, message{name: name, payload: map[string]any{"init": true}}) } sendMu.Unlock() + lifecycleMu.RUnlock() ctx2, cancel2 := context.WithCancel(context.Background()) defer cancel2() @@ -334,9 +348,14 @@ func connectionLoop() { closeErr := c.close() mu.Lock() - conn = nil + if conn == c { + conn = nil + } mu.Unlock() connectionState.Set(ConnectionDisconnected) + if generation != connectionGeneration.Load() { + return nil + } if loopErr != nil { return loopErr } @@ -390,6 +409,9 @@ func readLoop(ctx context.Context, c *hostConn) error { if err != nil { return err } + if c.generation != connectionGeneration.Load() { + return ErrSessionReset + } if err := json.Unmarshal(frame, &msg); err != nil { return err } @@ -831,9 +853,11 @@ func dedupEnabled(name string) bool { // Send queues or transmits a host component message. func Send(name string, payload any) { + lifecycleMu.RLock() + defer lifecycleMu.RUnlock() connect() if dedupEnabled(name) { - key := fmt.Sprintf("%s|%v", name, payload) + key := fmt.Sprintf("%d|%s|%v", connectionGeneration.Load(), name, payload) if _, ok, _ := sendCache.Get(context.Background(), key); ok { return } @@ -902,6 +926,41 @@ func SessionID() string { return sessionID } +// ResetSession closes the active SSC transport and discards all delivery +// state owned by its authenticated session. Live registrations are preserved; +// callers must unmount user-owned scopes before invoking it. +func ResetSession() { + lifecycleMu.Lock() + defer lifecycleMu.Unlock() + + connectionGeneration.Add(1) + connectionState.Set(ConnectionDisconnected) + mu.Lock() + current := conn + conn = nil + pending = nil + mu.Unlock() + sessionMu.Lock() + sessionID = "" + sessionMu.Unlock() + deliveryMu.Lock() + resumeToken = "" + nextOutbound = 0 + lastInbound = 0 + outbox = map[uint64]message{} + deliveryMu.Unlock() + callMu.Lock() + calls := pendingCalls + pendingCalls = map[string]chan actionReply{} + callMu.Unlock() + for _, reply := range calls { + reply <- actionReply{resetErr: ErrSessionReset} + } + if current != nil { + _ = current.close() + } +} + func sendMessage(c *hostConn, msg message) { sendMessageWithWriter(c, msg, writeMessage) } @@ -963,6 +1022,7 @@ func Call[Request, Response any](ctx context.Context, action string, request Req if action == "" { return zero, errors.New("hostclient: empty action name") } + lifecycleMu.RLock() connect() id := fmt.Sprintf("call-%d", callSequence.Add(1)) replyChannel := make(chan actionReply, 1) @@ -981,9 +1041,13 @@ func Call[Request, Response any](ctx context.Context, action string, request Req } else { sendMessage(current, msg) } + lifecycleMu.RUnlock() select { case reply := <-replyChannel: + if reply.resetErr != nil { + return zero, reply.resetErr + } if reply.err != nil { return zero, reply.err } diff --git a/hostclient/runtime_wasm_test.go b/hostclient/runtime_wasm_test.go index 1758fd66..ead7e449 100644 --- a/hostclient/runtime_wasm_test.go +++ b/hostclient/runtime_wasm_test.go @@ -4,12 +4,21 @@ package hostclient import ( "context" + "errors" + "fmt" + "sync/atomic" "testing" "time" js "github.com/rfwlab/rfw/v2/js" ) +var runtimeTestSequence atomic.Uint64 + +func uniqueRuntimeTestName(prefix string) string { + return fmt.Sprintf("%s-%d", prefix, runtimeTestSequence.Add(1)) +} + func TestGuardedLoopConvertsPanicAndNextLoopRuns(t *testing.T) { previous := js.OnRuntimePanic defer func() { js.OnRuntimePanic = previous }() @@ -43,7 +52,7 @@ func pendingCount() int { } func TestRegisterHandlerUnsubscribeQueuesWireUnsubscribe(t *testing.T) { - name := "scoped-handler" + name := uniqueRuntimeTestName("scoped-handler") before := pendingCount() unsubscribe := RegisterHandler(name, func(map[string]any) {}) if got := pendingCount() - before; got != 1 { @@ -76,7 +85,7 @@ func TestRegisterHandlerUnsubscribeQueuesWireUnsubscribe(t *testing.T) { } func TestStaleUnsubscribeDoesNotRemoveReplacementHandler(t *testing.T) { - name := "replacement-handler" + name := uniqueRuntimeTestName("replacement-handler") first := RegisterHandler(name, func(map[string]any) {}) second := RegisterHandler(name, func(map[string]any) {}) first() @@ -103,10 +112,11 @@ func TestSendRepeatedMessagesNotDeduped(t *testing.T) { // Dedup is opt-in per channel: after EnableSendDedup identical payloads within // the TTL window are dropped. func TestSendDedupOptIn(t *testing.T) { - EnableSendDedup("DedupHost") + name := uniqueRuntimeTestName("DedupHost") + EnableSendDedup(name) before := pendingCount() - Send("DedupHost", map[string]any{"cmd": "refresh"}) - Send("DedupHost", map[string]any{"cmd": "refresh"}) + Send(name, map[string]any{"cmd": "refresh"}) + Send(name, map[string]any{"cmd": "refresh"}) if got := pendingCount() - before; got != 1 { t.Fatalf("expected 1 queued message after dedup, got %d", got) } @@ -236,3 +246,153 @@ func TestPrepareInboundDeliveryResetsRejectedResume(t *testing.T) { t.Fatalf("rejected resume state was not reset: inbound=%d token=%q", currentInbound, currentToken) } } + +func TestResetSessionClearsIdentityDeliveryAndPendingCalls(t *testing.T) { + lifecycleMu.Lock() + savedGeneration := connectionGeneration.Load() + mu.Lock() + savedConn := conn + savedPending := pending + savedBinding := bindings["reset-live"] + _, hadBinding := bindings["reset-live"] + savedHandler := handlers["reset-live"] + _, hadHandler := handlers["reset-live"] + current := newHostConn() + closed := 0 + current.shutdown = func() error { closed++; current.fail(errConnectionClosed); return nil } + current.generation = savedGeneration + conn = current + pending = []message{{name: "old-user", payload: map[string]any{"cmd": "stale"}}} + bindings["reset-live"] = componentBinding{id: "component-live"} + handlers["reset-live"] = func(map[string]any) {} + mu.Unlock() + + sessionMu.Lock() + savedSession := sessionID + sessionID = "ssc-old" + sessionMu.Unlock() + + deliveryMu.Lock() + savedToken := resumeToken + savedNext := nextOutbound + savedInbound := lastInbound + savedOutbox := outbox + resumeToken = "resume-old" + nextOutbound = 4 + lastInbound = 7 + outbox = map[uint64]message{4: {name: "old-user", sequence: 4}} + deliveryMu.Unlock() + + callMu.Lock() + savedCalls := pendingCalls + reply := make(chan actionReply, 1) + pendingCalls = map[string]chan actionReply{"call-old": reply} + callMu.Unlock() + lifecycleMu.Unlock() + + t.Cleanup(func() { + lifecycleMu.Lock() + connectionGeneration.Store(savedGeneration) + mu.Lock() + conn = savedConn + pending = savedPending + if hadBinding { + bindings["reset-live"] = savedBinding + } else { + delete(bindings, "reset-live") + } + if hadHandler { + handlers["reset-live"] = savedHandler + } else { + delete(handlers, "reset-live") + } + mu.Unlock() + sessionMu.Lock() + sessionID = savedSession + sessionMu.Unlock() + deliveryMu.Lock() + resumeToken = savedToken + nextOutbound = savedNext + lastInbound = savedInbound + outbox = savedOutbox + deliveryMu.Unlock() + callMu.Lock() + pendingCalls = savedCalls + callMu.Unlock() + lifecycleMu.Unlock() + }) + + ResetSession() + + if closed != 1 { + t.Fatalf("transport closes = %d, want 1", closed) + } + if got := connectionGeneration.Load(); got != savedGeneration+1 { + t.Fatalf("generation = %d, want %d", got, savedGeneration+1) + } + mu.RLock() + currentConn := conn + queued := len(pending) + _, bindingPreserved := bindings["reset-live"] + _, handlerPreserved := handlers["reset-live"] + mu.RUnlock() + if currentConn != nil || queued != 0 { + t.Fatalf("connection state after reset: conn=%v pending=%d", currentConn, queued) + } + if !bindingPreserved || !handlerPreserved { + t.Fatal("reset removed a live binding or handler") + } + if got := SessionID(); got != "" { + t.Fatalf("session ID after reset = %q", got) + } + deliveryMu.Lock() + gotToken, gotNext, gotInbound, gotOutbox := resumeToken, nextOutbound, lastInbound, len(outbox) + deliveryMu.Unlock() + if gotToken != "" || gotNext != 0 || gotInbound != 0 || gotOutbox != 0 { + t.Fatalf("delivery after reset: token=%q next=%d inbound=%d outbox=%d", gotToken, gotNext, gotInbound, gotOutbox) + } + select { + case got := <-reply: + if !errors.Is(got.resetErr, ErrSessionReset) { + t.Fatalf("pending call error = %v, want ErrSessionReset", got.resetErr) + } + default: + t.Fatal("reset did not fail the pending call") + } +} + +func TestOldGenerationFrameIsRejectedAfterSessionReset(t *testing.T) { + lifecycleMu.Lock() + savedGeneration := connectionGeneration.Load() + connectionGeneration.Store(savedGeneration + 1) + mu.Lock() + savedHandler := handlers["old-generation"] + _, hadHandler := handlers["old-generation"] + deliveries := 0 + handlers["old-generation"] = func(map[string]any) { deliveries++ } + mu.Unlock() + lifecycleMu.Unlock() + t.Cleanup(func() { + lifecycleMu.Lock() + connectionGeneration.Store(savedGeneration) + mu.Lock() + if hadHandler { + handlers["old-generation"] = savedHandler + } else { + delete(handlers, "old-generation") + } + mu.Unlock() + lifecycleMu.Unlock() + }) + + stale := newHostConn() + stale.generation = savedGeneration + stale.deliver([]byte(`{"component":"old-generation","payload":{"value":"stale"},"sequence":1}`)) + err := readLoop(context.Background(), stale) + if !errors.Is(err, ErrSessionReset) { + t.Fatalf("stale frame error = %v, want ErrSessionReset", err) + } + if deliveries != 0 { + t.Fatalf("stale frame deliveries = %d, want 0", deliveries) + } +} From 35e43ecd8cfd27a8f6eb9b0f61ee50ad40eddbbd Mon Sep 17 00:00:00 2001 From: Pietro di Caprio Date: Tue, 1 Sep 2026 15:07:21 +0200 Subject: [PATCH 3/3] fix: document SSC reset error --- hostclient/runtime_foundation.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hostclient/runtime_foundation.go b/hostclient/runtime_foundation.go index 54fbd91e..b63a9285 100644 --- a/hostclient/runtime_foundation.go +++ b/hostclient/runtime_foundation.go @@ -144,6 +144,8 @@ type actionReply struct { resetErr error } +// ErrSessionReset is returned by an in-flight Call when ResetSession discards +// the authenticated SSC session that owned it. var ErrSessionReset = errors.New("hostclient: session reset") func decodeInitSnapshotPayload(raw any) *initSnapshotPayload {