Skip to content

feat(live): add the live-messaging and live-messaging-sw rows, not yet served - #2298

Merged
davideast merged 1 commit into
mainfrom
feat/2114-messaging-8-live-rows
Nov 6, 2026
Merged

davideast merged 1 commit into
mainfrom
feat/2114-messaging-8-live-rows

Conversation

@davideast

Copy link
Copy Markdown
Owner

Adds the live-messaging and live-messaging-sw row specs to @pyric/live. Each getToken and deleteToken record lists the Installations and FCM registration requests the call made, and onMessage and onBackgroundMessage make one record per delivery. The rows are not served yet: LIVE_ROWS lists them in M-10.

import { wrapLiveModule } from '@pyric/live';
import * as realMessaging from 'firebase/messaging';

const messaging = wrapLiveModule('live-messaging', 'firebase/messaging', realMessaging, site);
const m = messaging.getMessaging(app);
await messaging.getToken(m, { vapidKey });
// The observer gets, once Resource Timing reports the SDK's own fetches:
// {
//   module: 'firebase/messaging', name: 'getToken', outcome: 'resolved', projectId: 'demo-project',
//   requests: [
//     { api: 'installations', action: 'create', status: 200 },
//     { api: 'registrations', action: 'create', status: 200 },
//   ],
// }
// The host event carries the requests as detail.requests. No token, FID, VAPID key or URL.

await messaging.getToken(m, { vapidKey });
// Answered from the SDK's stored token: no request, so no record.

Risk

  • Unit evidence only. Nothing serves these rows until M-10, and M-11's scenario drives the real SDK.
  • observe.ts gains a rejectionOf hook beside resultOf. Only the Messaging row sets it. Every other row's rejections run the same report path as before, and all 207 @pyric/live tests pass.
  • firebase 12.19 (@firebase/messaging 0.13) also exports register, unregister, onRegistered and onUnregistered, the FID-based registration API. They send their own requests to fcmregistrations.googleapis.com. The plan does not cover them, so this PR leaves them as the SDK's own exports: they are neither recorded nor refused. M-10 needs to decide whether to observe them or refuse them before the row is served. onRegistered hands the app a FID, so a record of it must not carry its value.

A call's requests come from Resource Timing, which never touches the request

The row starts a PerformanceObserver on resource entries at the realm's first getToken or deleteToken. When the call settles, the row waits one task and calls takeRecords(), so the entry for the last request is in. It then keeps the entries for the two hosts that started after the call. Each entry is reduced to an API, an action and a status while it is read, and the URL is dropped:

Path Request
/v1/projects/<p>/installations Installations create
/v1/projects/<p>/installations/<fid>/authTokens:generate Installations generateAuthToken
/v1/projects/<p>/registrations registrations create
/v1/projects/<p>/registrations/<x> in deleteToken registrations delete
/v1/projects/<p>/registrations/<x> in getToken delete when a create follows it, else update

The status is left out when responseStatus reads 0, since the host refuses 0. A call keeps its last four requests, because another service's Installations call can start in the same window and the host takes at most four. Where the realm has no PerformanceObserver, every settled call is recorded, without requests.

A call that made no request makes no record, and a denial is still recorded

A resolved or rejected call with no request entry made no request. Either the stored token was valid, there was nothing to delete, or the SDK failed locally (messaging/permission-blocked). No record is made. A rejection whose error carries production's answer, an Installations customData.serverCode or a messaging/token-* failure, is recorded even with no entry. Capture M kept no Resource Timing entry for its three denials, so the row does not depend on one.

The code is read in order:

  1. The canonical name of the last failing request's status, using AI Logic's mapping (403 is permission_denied).
  2. An Installations error's customData.serverStatus, lowercased (invalid_argument).
  3. The SDK's code.

productionVerdict already reads permission_denied as denied.

Integration test

None, because the change has no runtime path: LIVE_ROWS does not list either row until M-10. The evidence is packages/live/test/messaging-row.test.ts, which wraps stand-ins with the SDK's signatures and a stand-in PerformanceObserver. Bun has no service worker, PushManager or IndexedDB. Each record is checked with the host's own readObservedCall and observedCallEvent. The real SDK runs in the replay's browser test (M-3) and the live-mode-messaging scenario (M-11).

Implementation notes
  • Files (5): packages/live/src/messaging.ts (new), packages/live/src/modules.ts, packages/live/src/observe.ts, packages/live/src/page-observer.ts, packages/live/test/messaging-row.test.ts (new).
  • @pyric/cli/vite/internal/supplied-rows re-exports ObservedCall from pyric/sandbox, which already has requests?: ObservedRequest[] (M-4). page-observer.ts typechecks against it with no change to @pyric/cli.
  • Boundary 3: both hosts read calls with readObservedCall, which already takes messaging and requests. The test reads every record through it, so nothing changes under packages/cli/src/serve/.
  • Boundary 5: the test deletes globalThis.PerformanceObserver and checks that the call is recorded without requests.
  • The status mapping comes from aiCode and is not copied, as the plan's "Left for later" asks, so ai.ts stays unchanged.
  • No record carries an FID, an FCM token, the VAPID key, the push auth secret or payload text. The test checks every record and host call for each value, including a 22-character FID-shaped auth value in a delivered payload.
  • Two Messaging calls running at once in one realm each take the other's requests. This is noted in messaging.ts.
  • On main the test fails at load: wrapLiveModule('live-messaging', ...) throws undefined is not an object (evaluating 'spec.hooks').
  • unshare -rn sh -c 'ip link set lo up && bun test' in packages/live: 207 pass, 0 fail (main: 191 pass once packages/live/dist is built). bun run typecheck in packages/live: clean. BASE_REF=origin/main bash scripts/ci/conformance-coupling-gate.sh: exit 0.

…t served

The rows wrap firebase/messaging and firebase/messaging/sw. getToken and
deleteToken records list the Installations and FCM registration requests
each call made, read from Resource Timing as API, action and status (the
status left out when the browser reports 0, the last four kept, never a
URL, FID or token). A call with no such request makes no record, unless
its error carries production's answer. onMessage and onBackgroundMessage
make one record per delivery, with no payload field. isSupported makes
none. The code is the last failing request's status name, then the
status an Installations error kept, then the SDK's code.

observe.ts gains a rejection hook beside the result hook, so a rejected
call can drop its record or name production's code, and LiveCallRecord
gains requests. page-observer.ts maps both specifiers to the messaging
service and passes requests to the host.

LIVE_ROWS does not list the rows yet (M-10), so there is no runtime path:
unit evidence only.

On main, packages/live/test/messaging-row.test.ts fails at load:
wrapLiveModule('live-messaging', ...) throws "undefined is not an object
(evaluating 'spec.hooks')", since the spec does not exist.
@davideast davideast mentioned this pull request Nov 6, 2026
@davideast
davideast merged commit 97a4961 into main Nov 6, 2026
29 checks passed
davideast added a commit that referenced this pull request Nov 6, 2026
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