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
60 changes: 58 additions & 2 deletions ts/packages/popup/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,62 @@ control, must initiate its own departure. `close` uses an
available retained handle and otherwise uses popup control, then releases both
the connection and popup.

### Observing closure

`connection.closed` also settles with `{ outcome: 'closed' }` when the connected
popup reports an unexpected document departure, including ordinary user closure,
Back or reload. The notification uses the authenticated carrier and works after
COOP severs the opener. Expected package navigation and isolation fallback do
not end the application's connection.

This is best-effort `pagehide` reporting, not a guaranteed window-close event.
Mobile termination may skip it, and provider pages run no package code. During
periods without a carrier, the application polls its retained handle every 250 ms.
If that handle becomes unavailable and no fallback is pending, the connection
fails with `popup-unavailable` immediately on detection, without a grace period.
The error covers both window closure and provider COOP severance; it cannot
identify which happened. `PopupError.code` identifies the failure; the host owns
user-facing wording.

A selected carrier survives handle severance. A pending fallback keeps recovery
open without a connection timeout until it resolves, rejects, or the caller
closes. Native-anchor launches gain the same detection after authentication binds
the handle; closure before that binding remains unobservable. Browser suspension
may delay polling. No heartbeat currently treats silence as closure. Neither
`closed` nor `failed` implies OAuth denial.

### Connection failures inside the popup

`closed` describes this endpoint's lifecycle, not the physical window or a user
intent. On Application, `{ outcome: 'failed', code: 'popup-unavailable' }` means
its retained handle became unavailable without a carrier or pending fallback;
it cannot distinguish user closure from COOP severance. A popup-originated
`DocumentDeparted` instead produces `{ outcome: 'closed' }` and can also mean
Back or reload. Planned Prover isolation preserves the carrier.

An Application failure does not stop an external provider page or notify a
severed popup. When the user returns to a participating popup document, that
document must handle its own `ready` rejection and show the failure locally:

```ts
try {
await connection.ready
} catch (error) {
status.textContent = error instanceof PopupError
? `Could not connect (${error.code}).`
: 'Could not connect.'
}
```

Without an opener or fallback, `ready` rejects with `fallback-unavailable`.
An opener that exists but does not answer gets the existing 30-second handshake
wait before fallback selection. An available fallback is awaited; a rejection
reports `fallback-failed`. Authentication failures report `handshake-rejected`.
These are connection/setup failures, not OAuth denial or proof of user closure.
The package provides programmatic errors; the host translates their codes into
UI text. Preserve `end.code` if observing `closed` too, so a generic closure handler cannot replace
the actual readiness error. No message from Application is needed to display it.

### Transitions that carry a reply

Delivery on one carrier is ordered, and a `navigate` control is delivered in
Expand Down Expand Up @@ -360,5 +416,5 @@ remain deferred or manual.

After `ready`, `connection.peerOrigin` identifies the authenticated peer; it is
`null` without a selected carrier. [Origin binding](docs/connection.md#authenticated-peer-origin)
describes preservation and fallback. ConnectionVersion 2 requires updated
Application, popup documents, and worker together.
describes preservation and fallback. Application, popup documents, and worker
use the same connection protocol version.
21 changes: 18 additions & 3 deletions ts/packages/popup/TEST_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Coverage status:

| ID | Assertion |
|---|---|
| POPUP-API-001 | A caller-defined message union is accepted by `PopupConnection<M>` without entering package-owned source, and `send` rejects the reserved `navigate` and `close-popup` discriminators. |
| POPUP-API-001 | A caller-defined message union is accepted by `PopupConnection<M>` without entering package-owned source, and `send` rejects the reserved `navigate`, `close-popup` and `document-departed` discriminators. |
| POPUP-API-002 | `on` registers one class per discriminator, rejects duplicates, calls its static `decode` exactly once, returns the same decoded object, and unsubscribes only that handler. |
| POPUP-API-003 | Unknown, unregistered, malformed, or decoder-rejected input closes the connection and reaches no caller handler. An exception thrown by a caller handler propagates untouched, closes nothing, and reaches no diagnostic. |
| POPUP-API-004 | Concurrent connections keep message registrations, native resources, controls, and delivery isolated by connection ID; a valid handshake for another connection ID, or any handshake from a window or origin other than the expected peer, is ignored rather than rejected. |
Expand All @@ -46,7 +46,7 @@ Coverage status:
| POPUP-CONTROL-001 | While native-anchor binding is pending, `navigate` performs no browser operation and leaves the same activation's default navigation intact. Without an active carrier, `navigate` and `close` use the retained handle only while it is non-null and not closed and emit no control message. |
| POPUP-CONTROL-002 | With an active carrier, application-endpoint `navigate` sends one exact canonical `Navigate` even while its retained handle appears usable; popup-endpoint `navigate` sends no control. HTTPS and HTTP on exact `localhost` or `127.0.0.1` at any valid port are accepted. Either popup-side path invokes replacement only after carrier continuity is preserved or prepared; malformed, credentialed, noncanonical, relative, disallowed-HTTP, and unpreparable navigations fail before browser navigation. Real-engine tests confirm a COOP-severed retained handle reports `closed`. |
| POPUP-CONTROL-003 | `close` uses a non-null, non-closed retained handle directly regardless of carrier state and otherwise sends one `ClosePopup` over an active carrier. It closes local resources, is idempotent, and is accepted only for a package-created script-closable popup. |
| POPUP-CONTROL-004 | Controls carry no version field because they travel only over a version-authenticated carrier. They are application-to-popup, one-shot per receiving document, unacknowledged, connection-bound, and private; wrong-direction, duplicate, replayed, unknown, and post-terminal controls perform no browser operation. `Navigate` may continue the logical connection in its destination, while `ClosePopup` terminates it. |
| POPUP-CONTROL-004 | Controls carry no version field because they travel only over a version-authenticated carrier. Navigation and close commands are application-to-popup, one-shot per receiving document, unacknowledged, connection-bound, and private; wrong-direction, duplicate, replayed, unknown, and post-terminal controls perform no browser operation. `Navigate` may continue the logical connection in its destination, while `ClosePopup` terminates it. |
| POPUP-CONTROL-005 | `navigateAway` never sends `Navigate`. The application endpoint navigates a non-null, non-closed retained handle directly, retires its carrier without `keep`, and accepts the next participating document's handshake; it rejects once the handle is unusable and performs no browser operation while native-anchor binding is pending. The popup endpoint releases its carrier and replaces itself without `keep`. Malformed destinations fail before any browser operation. |

## Carrier authentication and selection
Expand All @@ -58,7 +58,7 @@ Coverage status:
| POPUP-CONNECTION-003 | One logical connection survives repeated participating-document replacements: `navigate` preserves a usable carrier when possible and otherwise replaces it transparently; callers never manage carrier identity, count, lifetime, or reconnection. If no carrier can continue or be established, the connection fails closed. |
| POPUP-CONNECTION-004 | `connect` invokes a supplied fallback constructor exactly once with the connection-lifetime signal and observes its promise without awaiting it; `accept` invokes its constructor once only after MessagePort becomes unavailable. The constructor returns only an authenticated `Carrier`; connection creates no substitute `MessageChannel` and queues no caller value outside that carrier. MessagePort selection retains the unused application fallback. No-carrier navigation arms only a fresh document-local MessagePort operation; popup-side navigation from RTC uses its package-private lifecycle hooks to prepare the target and retain a pending replacement before navigating, then installs only its authenticated result. Connection closure aborts every pending operation. |
| POPUP-CONNECTION-005 | Without a fallback constructor, successful MessagePort use emits no fallback diagnostic; fallback selection records exactly one sanitized `fallback-unavailable` failure and closes. |
| POPUP-CONNECTION-006 | Carrier loss, endpoint loss, popup closure, background suspension, and continuity loss are never delivery, cancellation, success, or recovery. Resumed delivery preserves order. While the popup is non-participating after a connected navigation, every `send` and `navigate` succeeds locally and delivers nothing; after `navigateAway` the carrier is retired and `send` throws. `closed` settles exactly once with the terminal outcome and `ready` rejects with the same code when selection fails. |
| POPUP-CONNECTION-006 | Carrier loss, endpoint loss, popup closure, background suspension, and continuity loss are never delivery, cancellation, success, or recovery. Resumed delivery preserves order. While the popup is non-participating after a connected navigation, every `send` and `navigate` succeeds locally and delivers nothing; after `navigateAway` the carrier is retired and `send` throws. `closed` settles exactly once with the terminal outcome and `ready` rejects with the same code when selection fails. Without a carrier or pending fallback, an unavailable bound handle fails on detection; a selected carrier or pending fallback prevents that failure. |
| POPUP-CONNECTION-007 | Both constructors accept an exact lowercase RFC 4122 UUIDv4 `connectionId` and reject uppercase, noncanonical, malformed, wrong-version, and wrong-variant strings before carrier, keeper, or signaling work. Caller integration generates a different `crypto.randomUUID()` value for every logical connection and never reuses a retired value. |
| POPUP-CONNECTION-008 | `PopupConnection.navigate()` directly between same-origin participating documents preserves a MessagePort or prepares a nontransferable carrier. Across origins, including across sites, it never gives a MessagePort to the source origin's worker: it retires that popup endpoint and the allowed destination establishes a fresh carrier through its opener or fallback. A navigation outside that API loses the current carrier. A later participating document may establish the first RTC carrier from the still-unused initial fallback without round metadata; after RTC is active, an unmanaged navigation terminates the logical connection without restarting round zero. |
| POPUP-CONNECTION-009 | `connect` and `accept` copy nonempty, duplicate-free sets of canonical origins: HTTPS, or HTTP on exactly `localhost` or `127.0.0.1` at any valid port. Scheme and effective port match exactly; lookalikes, alternate loopback spellings, and other HTTP hosts fail. Every initial, native-anchor, replacement, MessagePort, and RTC participant binds one exact authenticated member of the peer's set. Sequential popup participants may use different admitted origins without changing the logical connection ID or caller registrations. `accept` alone also takes `'*'`, subject to the same URL and authentication rules; `connect` rejects a wildcard. An empty set or duplicate, malformed, noncanonical, credentialed, or unapproved origin fails before selection or caller delivery. |
Expand Down Expand Up @@ -118,3 +118,18 @@ POPUP-KEEPER-001: both endpoint origins before/after readiness and retirement;
origin preservation with queued delivery; destination allowlist rejection on
restoration; and missing, malformed, or unlisted fallback origins rejected before
subscription. Real browser coverage exercises restored origins through isolation.

### Departure coverage

`POPUP-CONNECTION-006` / `POPUP-CONTROL-004` additionally cover the exact,
popup-to-application `DocumentDeparted` notification: ordinary closure before
and after COOP, idempotent termination, inert failed reporting, wrong-direction
rejection, and suppression during planned navigation. Unit tests preserve silent
carrier loss as unavailable, not closure. Browser checks cover `pagehide` delivery,
provider-page closure after scripted and native-anchor launches, provider COOP
without a fallback, and preserved-carrier continuity despite a severed handle.
Unit checks cover no carrier/no fallback failure on detection, pending fallback
beyond five seconds, later fallback resolution/rejection, explicit closure during
recovery, pre-binding absence, pre-readiness failure, and polling cleanup.
Forced mobile termination and closure before native-anchor binding remain
best-effort limitations; emulation does not qualify physical mobile lifecycle.
41 changes: 30 additions & 11 deletions ts/packages/popup/docs/connection.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Continuity is best-effort: preserving the logical connection does not promise
delivery across carrier retirement or replay messages lost during navigation.

```ts
type ConnectionVersion = 2
type ConnectionVersion = 1
```

`ConnectionVersion` exact-matches the connection's private authentication,
Expand Down Expand Up @@ -315,10 +315,9 @@ opener relationship long enough to authenticate and transfer the carrier port.
The anchor is a compatibility hedge for an environment or embedding policy
which rejects scripted popup creation, not a second user flow. It must exist
before activation so the fallback proceeds in the same tap. Both paths use the
same target and create one script-closable top-level traversable. The scripted
path exposes `popup.closed` only to decide whether a no-carrier direct operation
can be attempted; before fallback binding no handle exists to observe. Closure
is never delivery, cancellation, or another caller-protocol outcome.
same target and create one script-closable top-level traversable, using the
retained handle for direct operations and unavailable-window detection. Before native-anchor binding no handle exists to
observe. Closure is never delivery, cancellation, or another caller-protocol outcome.

The application lifecycle object and both connection endpoints expose:

Expand Down Expand Up @@ -476,9 +475,16 @@ Both constructors return synchronously and select carriers afterwards, so
handlers registered before the caller yields precede every delivery; `ready`
settles once a carrier is selected and rejects with the failure code if the
endpoint failed first. `closed` settles exactly once with the connection's
terminal outcome, the only channel through which a failure without an
invoking operation reaches the caller. For each inbound carrier
value, the connection reads only a bounded string `type` from a plain record,
terminal outcome, including an authenticated best-effort document-departure
notification. Without a selected carrier or a pending fallback, an unavailable
retained handle instead fails with `popup-unavailable`: closure and provider COOP
severance are indistinguishable but neither can recover on this path. Detection
polls every 250 ms with no additional timeout; terminal cleanup stops polling.
A pending fallback has no connection-layer deadline; its rejection removes that
recovery option, while resolution installs the authenticated carrier. Existing
carriers remain usable despite handle severance. Unobserved loss and silence
alone still remain pending. This is the only channel through which a failure
without an invoking operation reaches the caller. For each inbound carrier value, the connection reads only a bounded string `type` from a plain record,
selects the registered `MessageType`, calls `decode` exactly once, and invokes
that handler. An unknown or unregistered type, malformed routing discriminator,
or thrown decode closes the connection and delivers no message. An exception
Expand All @@ -487,6 +493,18 @@ changes no connection state. The registered set therefore enforces participant
direction without hardcoding protocol types in the connection; the handler
still enforces state and order.

The endpoint's terminal outcome does not establish the physical window's state.
An Application endpoint can fail while an external provider continues running;
the returned popup then independently attempts connection. Its host MUST handle
`ready` rejection locally and retain the failure code if it also observes `closed`.
In particular, a simultaneous failed `closed` outcome must not mask readiness
failure with a generic "connection closed" message. `PopupError.code` identifies
the failure; the host owns its display text.
`PopupError.message` contains the code without user-facing explanation. An absent
opener with no configured fallback fails locally with `fallback-unavailable`,
without needing Application to send a message. A silent opener retains the
existing bounded handshake wait before fallback selection.

`navigate` is available on both connection endpoints. The application endpoint
always sends the private control defined by [popup control](control.md) when a
carrier is active. Without one, it uses its exact retained `WindowProxy` only
Expand Down Expand Up @@ -743,9 +761,10 @@ continuity.
## Versioning

The package supplies one `ConnectionVersion` to both endpoints; there is no
runtime negotiation. Compatible implementation changes keep the version.
Breaking private authentication, carrier, signaling, framing, or continuity
controls increment it independently of every caller protocol.
runtime negotiation. The unpublished implementation remains at version 1.
After publication, breaking private authentication, carrier, signaling, framing,
or continuity changes increment it independently of every caller protocol;
compatible changes keep the version.

## Authenticated peer origin

Expand Down
Loading