Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion .github/workflows/test-build-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
47 changes: 44 additions & 3 deletions bench/todomvc/rfw/wasm_loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
51 changes: 22 additions & 29 deletions cmd/rfw/build/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"
Expand Down
Loading
Loading