feat(popup): implement @libid/popup without WebRTC - #25
Merged
Merged
Conversation
Wondertan
force-pushed
the
feat/popup-package
branch
from
September 3, 2026 12:42
7e1551d to
eb62d6e
Compare
Wondertan
force-pushed
the
feat/popup-package
branch
2 times, most recently
from
September 3, 2026 15:48
eb62d6e to
b77aa29
Compare
Wondertan
force-pushed
the
docs/popup-connection-architecture
branch
from
September 3, 2026 16:15
8a14db9 to
f6a3c85
Compare
Wondertan
force-pushed
the
feat/popup-package
branch
2 times, most recently
from
September 3, 2026 16:31
7a63268 to
eca17ff
Compare
Wondertan
force-pushed
the
feat/popup-package
branch
3 times, most recently
from
September 5, 2026 21:03
c86b7b8 to
9f24781
Compare
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
force-pushed
the
feat/popup-package
branch
from
September 13, 2026 14:00
64ccc55 to
b8dec84
Compare
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the
@libid/popuppackage 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.@libid/popup/worker.PopupConnection.peerOriginexposes 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