Skip to content

feat(popup): implement @libid/popup without WebRTC - #25

Merged
Wondertan merged 24 commits into
mainfrom
feat/popup-package
Sep 13, 2026
Merged

Wondertan merged 24 commits into
mainfrom
feat/popup-package

Conversation

@Wondertan

@Wondertan Wondertan commented Sep 3, 2026

Copy link
Copy Markdown
Member

Implements the @libid/popup package and carries its documentation. The normative transport contract it satisfies is #22. Popup creation on both paths, the MessagePort carrier, the Service Worker port keeper, popup control, diagnostics, and the fallback seam. No WebRTC.

  • Zero runtime dependencies; worker handler behind @libid/popup/worker.
  • Unit and Playwright coverage spans Chromium, Firefox, WebKit and mobile emulation; real-device requirements remain manual.
  • TEST_PLAN.md records what is automated, deferred (RTC), and manual (real devices).

PopupConnection.peerOrigin exposes the authenticated carrier's selected origin and is null when no carrier is selected. Preserved ports retain that binding; replacement and fallback recheck admission. Application, popup documents and keeper Worker must deploy ConnectionVersion 2 together.

Rendered entry points:

Targets main. The normative popup specification is maintained independently in #22; ceremony implementation #28 is stacked on this PR.

https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw

@Wondertan
Wondertan requested a review from xgreenx September 3, 2026 09:25
@Wondertan
Wondertan force-pushed the feat/popup-package branch 2 times, most recently from eb62d6e to b77aa29 Compare September 3, 2026 15:48
@Wondertan
Wondertan force-pushed the docs/popup-connection-architecture branch from 8a14db9 to f6a3c85 Compare September 3, 2026 16:15
@Wondertan
Wondertan force-pushed the feat/popup-package branch 2 times, most recently from 7a63268 to eca17ff Compare September 3, 2026 16:31
@Wondertan
Wondertan force-pushed the feat/popup-package branch 3 times, most recently from c86b7b8 to 9f24781 Compare September 5, 2026 21:03
The @libid/popup package documentation as reviewed in #22: README,
connection, control, MessagePort and WebRTC carriers, test plan, and
metrics. The normative transport contract lives in specs/.

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
Add the @libid/popup package through the lifecycle phase of the
implementation plan: the wire leaf (message contract, reserved controls,
validators), sanitized diagnostics, PopupWindow with the scripted and
native-anchor paths, the MessagePort handshake and carrier, the Service
Worker port keeper with its `@libid/popup/worker` entry, and the two
connection endpoints with carrier selection and popup control.

Unit tests run in Node over real MessageChannel ports with in-memory
window and worker-scope fakes; they cover the API, window, control,
connection, port, keeper, and diagnostic rows of TEST_PLAN.md that need
no real browser. A build check keeps `bind` and worker-global types out
of the public declarations.

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
Add the Playwright harness: three cross-site HTTPS origins, a
participating page, a COOP-isolated participating page, a
non-participating page, and the popup-origin worker script composed the
way a host would. The spec covers scripted and native-anchor creation,
opener authentication, application-driven navigation into and out of
isolation over one preserved port, close after the opener is severed,
popup-initiated navigation, port expiry across a long non-participating
hop with re-establishment, and the fail-closed paths. All 60 cases pass
on the five-project matrix.

Two implementation changes surfaced by the browsers: the popup resolves
its Service Worker registration per use, since the first participating
document registers the worker after `accept` has started, and waits
briefly for an installing worker before preserving a port. The stale
layout READMEs are removed and the internal declarations in
message-port.md now match the code.

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
TEST_PLAN.md states which rows the unit and browser suites cover, which
are deferred with WebRTC, and which remain real-device gates.
message-port.md documents the keeper reply deadline and the per-use
registration lookup with its wait for an installing worker. README gains
a testing section.

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
- `isRecord` accepts only plain or null-prototype objects.
- A carrier that rejects a value fails the logical connection; the
  `Navigate` control goes through the same guard as caller messages.
- `PopupWindow.current` rejects an embedded document, and a handle or
  opener whose `closed` throws counts as absent.

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
`connect` takes `allowedPopupOrigins`; both endpoints copy a nonempty,
duplicate-free set of canonical HTTPS origins and reject anything else.
The application accepts a handshake from any member and answers the
exact origin the browser stamped on it, so sequential popup documents
may sit on different origins or sites under one connection.

A cross-origin replacement cannot keep the port in the source origin's
worker, so the popup endpoint retires and the destination authenticates
a fresh carrier through its opener or fallback. Unit tests cover the
origin set and the retirement; the e2e server gains a second popup
origin on another site with a participating hop that re-handshakes and
an isolated hop that fails closed without a fallback.

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
`navigateAway(url)` is for non-participating destinations such as an
identity platform's consent page. The application endpoint navigates its
retained handle directly, never sends the destination over the carrier,
retires the carrier without preserving it, and keeps listening for the
next participating document; it rejects once the handle is unusable. The
popup endpoint releases its carrier and replaces itself without the
keeper. `navigate(url)` stays the continuity-preserving operation.

`accept` takes `allowedApplicationOrigins: readonly string[] | '*'`. The
wildcard binds any canonical HTTPS origin the browser observed on the
opener's handshake and rejects an opaque or non-HTTPS one; an empty list
stays invalid and `connect` never accepts a wildcard.

Unit tests cover both operations and the wildcard matrix; the e2e server
gains an `/p-any` deployment and cases for navigation away from both
endpoints and an unlisted application connecting to it.

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
Security and correctness:
- A caller handler's exception propagates untouched instead of being
  caught as a decode failure, so no raw error text reaches diagnostics
  and a handler bug no longer tears the connection down.
- The popup endpoint hands its own lifetime signal to the handshake and
  the fallback; close() now aborts them. A late keep acknowledgement after
  close no longer navigates.
- The worker acts only on its own keep and claim records, leaves the
  host's traffic and ports untouched, guards an unparsable client URL,
  and no longer forces skipWaiting or clients.claim.
- A silent worker on the popup origin is treated as holding nothing, so
  an unrelated Service Worker never blocks a fresh handshake.
- A handshake from another window or an unlisted origin is ignored, not
  fatal; only a malformed record from the expected peer fails the
  connection, and it is reported once.
- A fragment-only navigation is rejected before any port leaves the
  carrier; a keep whose transfer threw closes the port it kept.

API:
- `ready` and `closed` on every connection; `closed` settles exactly once
  with the terminal outcome and its stable code.
- `PopupError` with a `code` from the exported `PopupErrorCode` union.
- `PopupConnection<Out, In = Out>` makes a wrong-direction send a type
  error.
- `accept` returns synchronously so handlers registered before yielding
  precede every delivery.
- `PopupWindow` is not constructible; the allowlist union is inlined.

Simplifications: the registry is part of the endpoint and no longer
returns codes through Error.message; the opener deadline resolves null;
the version constant lives in the wire leaf; single-valued parameters
are gone; the two-second keeper test uses fake timers.

Docs and tests follow: 73 unit tests, 85 Playwright cases green.

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
Caller messages sent before a Navigate control reach the popup's handlers
before the popup acts on the control, on every destination, so a
transition that carries the application's reply is driven by the side
that has finished talking. Messages sent after the control cannot reach
the departing document. Covered by a unit case and a cross-site browser
case (POPUP-CONNECTION-010).

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
`PopupWindow.open(target, features?)` always requests a separate window
with the `popup` feature and passes an optional size or position string
through; a string that would sever the opener is rejected. The
native-anchor fallback and mobile browsers still present a tab, which
changes no rule.

The browser suite asserts the presentation by requested size, since
Chromium drops the BarProp flags after a cross-origin navigation while
the size persists in every engine; the anchor path asserts a tab.

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
…ally

`accept` gains `isolationFallbackUrl`. Its presence requires cross-origin
isolation of the accepting document and names a same-origin replacement,
resolved against the current document with the fragment inherited
unless the value spells its own. After selecting its carrier and before
installing delivery or settling `ready`, a document that is not
isolated keeps the still-unstarted port through the worker, so every
value the application already sent travels with it, settles `closed`,
and replaces itself with the fallback; the fallback restores the port,
becomes ready, and delivers those values exactly once. A fallback that
stays non-isolated fails with `isolation-unavailable` instead of
looping. The endpoint waits briefly for a worker registration the host
is making in the same document.

A host serves one document with Document-Isolation-Policy for engines
that honour it and a COOP fallback for the rest; the application
observes one connection and calls ordinary `navigate`.

The e2e origins move to `.localhost` names, which every engine resolves
locally: the public wildcard domains stalled for seconds under DNS
load and made the matrix flaky. Two new browser cases cover the DIP
path, the fallback path with a handshake-time send delivered once, and
the loop guard. 82 unit tests; 98 browser cases pass, 2 skip where DIP
isolates directly.

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
`navigate` and `navigateAway` take a fragment-free URL and, separately,
optional `URLSearchParams` serialized at the call and appended as the
fragment. The fields are opaque protocol data: nothing is reserved,
parsed, or tied to the connection ID. An inline fragment, even an empty
`#`, is rejected on every public URL argument, `isolationFallbackUrl`
included. Application-initiated navigation carries the serialized
destination in the existing `Navigate` control; popup-initiated
navigation discloses nothing to the application.

`PopupWindow.current(fragment?)` adopts the fragment as the host
captured it, so a bootstrap may clear the URL before importing the
package; the endpoint keeps a snapshot and the isolation fallback always
carries it. Fragment data never enters the worker, storage, or
diagnostics.

Tests cover cross-origin navigation with fields, serialization and
mutation independence, inline rejection, capture before clearing,
fallback preservation, and non-disclosure; the e2e popup page now
captures and clears its URL before accepting. 86 unit tests; 98 browser
cases pass, 2 skip where DIP isolates directly.

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
When the opener is already severed and the selected carrier came from
the fallback constructor, a document that must be isolated can no
longer keep a port. It now drives the carrier seam the docs reserve
for that case: the popup side prepares its successor through
`prepareNavigation`, the endpoint retires the carrier and replaces the
document with the isolation fallback under the usual fragment rules,
the isolated destination establishes a fresh carrier through its own
constructor, and the application installs the successor its carrier
reports through `onReplacement` without ending the logical connection
or reusing the retired one. The intermediate document never becomes
ready or delivers. The same hooks now serve popup-side navigation over
such a carrier on any origin. A carrier with neither hook still fails
closed.

Between the retirement and the successor's authentication, values the
application sends succeed locally and are lost; the documentation says
so and nothing queues or replays them.

Fixing a MessagePort subtlety this exposed: the carrier no longer
assigns `onmessage`, even to null, on a port it never listened on,
because that assignment starts the port and dispatches queued values
into the void before a transfer. Firefox lost the handshake-time value
on the fallback path for exactly that reason.

A fake signaling hub plays the carrier in tests: the complete severed-
opener path with bidirectional messages afterwards, cancellation during
preparation, failed reconnection, and a fallback that stays
non-isolated. 90 unit tests; 98 browser cases pass, 2 skip where DIP
isolates directly.

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
A non-isolated document whose only carrier source is the fallback
constructor no longer establishes that carrier, retires it, and reconnects
in the fallback. It replaces itself first, and the isolated destination
constructs the only carrier from the same still-unused signaling round.
No connection is spent on the intermediate document, and the application,
which holds no carrier until then, cannot send into a gap.

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01DKLAmGeP1xjb3HEPNkrKLQ
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
PopupWindow.current() picked the registration controlling the document,
so on an origin with a nested registration of the same script it kept
ports in the wrong worker and the next document claimed nothing. An
optional scope now names the exact registration used for claim, keep,
and activation waits; nothing else is ever substituted. The registration
may not exist yet at construction, and one an engine exposes before
attaching its installing worker is waited for up to the reply deadline.

The browser suite registers a stale nested worker on every popup page
alongside the root one and passes the root scope.

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01DKLAmGeP1xjb3HEPNkrKLQ
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
The mismatched-echo case raced two zero-delay dispatches against a 5 ms
timer and lost under CI load.

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01DKLAmGeP1xjb3HEPNkrKLQ
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
A 5 ms timer can fire before the event loop delivers a MessagePort value
posted just before a stall, which made port tests flake under CI load.
tick now follows the timer with two further loop turns.

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01DKLAmGeP1xjb3HEPNkrKLQ
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
…explicit scope override

Continuity needs the departing and the claiming document to reach the
same worker. By default a keep now targets the registration that will
control the destination and a claim asks every registration on the
origin, so a stale nested registration sharing the root's script no
longer strands the port, including across a non-participating hop.
PopupWindow.current(fragment, { scope }) pins both to one exact
same-origin registration for hosts that want no other worker involved;
it may not exist yet at construction.

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01DKLAmGeP1xjb3HEPNkrKLQ
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
Merge the normative popup continuity update and backport exact-loopback HTTP origin handling from the ceremony implementation. Preserve logical connections at best effort without promising delivery across carrier retirement.

Correct the signaling test seam to authenticate replacement carriers only at the destination, and cover the loss window and loopback isolation continuity. Validated with 100 unit tests, 108 passing browser cases (2 conditional skips), type checks, build, and formatting.

Assisted-by: GPT-5
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
Retain the browser-authenticated origin with MessagePort and keeper ownership,
and validate restored and fallback carrier bindings before readiness or isolation
handoff. Expose the selected origin until local carrier retirement.

Increment ConnectionVersion to 2 for the keeper record change.

Assisted-by: GPT-6
Signed-off-by: Wondertan <hlibwondertan@gmail.com>
@Wondertan
Wondertan changed the base branch from docs/popup-connection-architecture to main September 13, 2026 14:00
Use successive screen positions for unpositioned popup windows, then
stagger them when the row fills. Preserve explicit caller positioning
and leave placement to the browser when native-anchor fallback is used.

Signed-off-by: Wondertan <hlibwondertan@gmail.com>
Assisted-by: GPT-6
@Wondertan
Wondertan merged commit e0d2a23 into main Sep 13, 2026
3 of 4 checks passed
@Wondertan
Wondertan deleted the feat/popup-package branch September 14, 2026 11:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant