Skip to content

feat(imessage): typing event + unified subscribeEvent routing - #41

Open
Ryan Zhu (underthestars-zhy) wants to merge 2 commits into
mainfrom
ryan/feat-imessage-typing
Open

feat(imessage): typing event + unified subscribeEvent routing#41
Ryan Zhu (underthestars-zhy) wants to merge 2 commits into
mainfrom
ryan/feat-imessage-typing

Conversation

@underthestars-zhy

@underthestars-zhy Ryan Zhu (underthestars-zhy) commented Apr 29, 2026

Copy link
Copy Markdown
Member

Summary

  • Collapses the split subscribeMessages / custom-event-streams paths into a single subscribeEvent(name) entry point on PlatformRuntime. All events — including messages — flow through one broadcaster-per-(platform, event) map, share one upstream subscription across consumers, and yield uniform [Space, payload] tuples (the producer's space field is stripped from the payload half).
  • Removes the spectrum-level custom event fan-in (CustomEventStreams, the lazy Proxy on SpectrumInstance, createCustomEventStream, mergedEventStreams). Non-messages events are now consumed only through the platform-specific accessor (e.g. imessage(app).typing); the app.<event> shape is gone.
  • Adds an iMessage typing event backed by a new resilient subscribeTyping stream — wraps client.chats.subscribe(), filters for chat.typingIndicator, and reconnects on failure with exponential backoff + jitter (shared RECONNECT_INITIAL_DELAY_MS / RECONNECT_MAX_DELAY_MS from resumable-stream). Local clients yield an empty stream.
  • Tightens EventProducer so every event payload must extend ProviderEventRecord ({ space: { id } }), and updates PlatformInstance to surface [PlatformSpace<Def>, EventPayload<...>] for non-messages events.

Usage

import { Spectrum } from "spectrum-ts";
import { imessage } from "spectrum-ts/providers/imessage";

const app = await Spectrum({
  projectId: process.env.PROJECT_ID,
  projectSecret: process.env.PROJECT_SECRET,
  providers: [imessage.config()],
});

// Per-platform event accessor (only path for non-`messages` events)
for await (const [space, typing] of imessage(app).typing) {
  if (typing.isTyping) {
    console.log(`${typing.displayName ?? "someone"} is typing in ${space.id}`);
  }
}

Note: a previous app.typing shape (the spectrum-level proxy) is removed in this PR — consumers must go through imessage(app).typing.

Changes

File Change
packages/spectrum-ts/src/spectrum.ts Replaces messageBroadcasters + customEventStreams with one eventBroadcasters map keyed by platform:eventName. New createProviderEventStream / getOrCreateEventBroadcast handle every event uniformly; messages still runs through wrapProviderMessage and flattenGroups while other events yield the raw record minus space. The customEventProxy and outer Proxy wrapper around the returned instance are removed.
packages/spectrum-ts/src/platform/types.ts Adds ProviderEventRecord and EventPayload<P>; tightens EventProducer and PlatformDef.events index signature to require records that carry space. Replaces subscribeMessages on PlatformRuntime with subscribeEvent(name). Updates PlatformInstance mapped type to yield [PlatformSpace<Def>, EventPayload<...>] per event. Drops the unused ExtractCustomEventNames / ToCustomEventVariant / AllCustomEventNames / UnifiedCustomEvent / CustomEventStreams machinery.
packages/spectrum-ts/src/platform/define.ts Per-platform PlatformInstance accessors are now generated for every event (including messages) by lazily calling runtime.subscribeEvent(name) and caching the resulting iterable per-name, so a second for await reuses the broadcaster's subscription. The old eager eventProperties / dedicated messages Object.defineProperty are removed.
packages/spectrum-ts/src/providers/imessage/remote/typing-events.ts New file. subscribeTyping(clients) returns a merged ManagedStream<IMessageTypingRecord>. Per-client stream filters chat.typingIndicator, emits { space: { id: chatGuid }, isTyping, displayName, timestamp }, and reconnects with backoff (resets the delay after any successful event). Errors are logged, never propagated.
packages/spectrum-ts/src/providers/imessage/index.ts Wires the typing event into the iMessage platform definition (remote only — local yields an empty async iterable).

Behavior notes

  • Bot's own typing is not filtered: the SDK doesn't expose an isFromMe flag on the typing indicator. Consumers that need to ignore self-typing should branch on displayName.
  • Two cloud clients in the same chat will each emit their own copy of a single typing event — same caveat as the existing poll stream.
  • subscribeEvent(name) returns undefined when the platform doesn't define that event, so the merged messages stream and platform-level accessors silently skip unsupported events instead of throwing.

Test plan

  • bun x ultracite check passes
  • bun run typecheck passes
  • Manual: connect to a remote iMessage client, iterate imessage(app).typing, confirm isTyping toggles when a peer starts/stops typing
  • Manual: kill the upstream chats.subscribe() connection mid-iteration, confirm the typing stream reconnects with backoff and resumes emitting
  • Manual: confirm for await (const [space, msg] of app.messages) still works across iMessage + WhatsApp providers
  • Manual: confirm a second for await (...of imessage(app).messages) reuses the broadcaster (no duplicate upstream subscription)
  • Manual: confirm app.stop() shuts down the typing stream cleanly

View in Codesmith
Need help on this PR? Tag @codesmith with what you need.

  • Let Codesmith autofix CI failures and bot reviews

Summary by CodeRabbit

  • New Features

    • Added remote typing indicator support for iMessage, providing real-time visibility when contacts are actively typing in conversations.
  • Improvements

    • Unified event handling architecture across all platform events for consistent access patterns.
    • Event payloads restructured to include space context information.

Replaces the split `subscribeMessages` / custom-event-streams approach
with a single `subscribeEvent(name)` path that handles all events,
including `messages`, uniformly. All events now yield `[Space, payload]`
tuples; `space` is stripped from the payload half. Broadcasters are
keyed by `platform:eventName` instead of platform alone.

Also adds an iMessage `typing` event backed by a new resilient
`subscribeTyping` stream with exponential-backoff reconnection.
Custom event fan-in (`CustomEventStreams`, `mergedEventStreams`,
and the lazy proxy) are removed. Non-`messages` events are now
only accessible through platform-specific accessors. The messages
stream is simplified to a direct merge without the generic
`createMergedEventStream` abstraction.
Copilot AI review requested due to automatic review settings April 29, 2026 04:42
@coderabbitai

coderabbitai Bot commented Apr 29, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

The PR unifies the platform event subscription system by replacing separate handling paths for messages and custom events with a single runtime.subscribeEvent(eventName) interface, where subscriptions are cached per event name. Concurrently, iMessage typing event support is added via a new stream provider with reconnection logic, and spectrum-level event handling is refactored to use a per-platform broadcaster cache instead of a proxy-based custom event mechanism.

Changes

Cohort / File(s) Summary
Platform Event System
packages/spectrum-ts/src/platform/define.ts, packages/spectrum-ts/src/platform/types.ts
Refactors event subscription from dual-path (messages vs custom) to unified lazy caching via runtime.subscribeEvent(eventName). Introduces ProviderEventRecord, EventPayload utility, and updates PlatformRuntime interface to replace subscribeMessages() with generalized subscribeEvent(). Event iterables now emit [Space, payload] tuples.
iMessage Typing Events
packages/spectrum-ts/src/providers/imessage/remote/typing-events.ts, packages/spectrum-ts/src/providers/imessage/index.ts
Implements new iMessage typing event stream with subscribeTyping export. Defines IMessageTypingRecord payload and clientTypingStream handler with jittered exponential backoff reconnection. Wires typing into platform events via new events.typing property.
Spectrum Integration
packages/spectrum-ts/src/spectrum.ts
Replaces custom-event proxy mechanism with unified per-platform event broadcaster cache keyed by (platform, eventName). Introduces createProviderEventStream to normalize event payloads and flattenGroups behavior for messages. Removes CustomEventStreams from SpectrumInstance type.

Sequence Diagram(s)

sequenceDiagram
    participant Consumer
    participant PlatformInstance
    participant EventCache
    participant Runtime
    participant EventBroadcaster

    Consumer->>PlatformInstance: Access im.typing<br/>(or other event)
    PlatformInstance->>EventCache: Check cache for event
    alt Event cached
        EventCache-->>PlatformInstance: Return cached async iterable
    else Event not cached
        PlatformInstance->>Runtime: subscribeEvent("typing")
        Runtime->>EventBroadcaster: Subscribe to typed event stream
        EventBroadcaster-->>Runtime: Return ManagedStream
        Runtime-->>PlatformInstance: Return ManagedStream
        PlatformInstance->>EventCache: Cache iterable for "typing"
        EventCache-->>PlatformInstance: Return and cache async iterable
    end
    PlatformInstance-->>Consumer: Return async iterable
    Consumer->>Consumer: Iterate over [Space, payload] tuples
Loading
sequenceDiagram
    participant subscribeTyping
    participant AdvancedIMessage
    participant ChatClient
    participant clientTypingStream

    subscribeTyping->>subscribeTyping: Iterate over clients
    loop For each AdvancedIMessage client
        subscribeTyping->>clientTypingStream: Subscribe client
        clientTypingStream->>ChatClient: chatTypingIndicators.stream()
        ChatClient-->>clientTypingStream: Typing event stream
        clientTypingStream->>clientTypingStream: Filter chat.typingIndicator events
        alt On error
            clientTypingStream->>clientTypingStream: Log error, close subscription
            clientTypingStream->>clientTypingStream: Wait (jittered exponential backoff)
            clientTypingStream->>ChatClient: Resubscribe
        else On success
            clientTypingStream-->>subscribeTyping: Emit IMessageTypingRecord
        end
    end
    subscribeTyping-->>subscribeTyping: Merge all streams into ManagedStream
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Suggested labels

release

Poem

🐰 A rabbit hops through streams of change,
where events dance in cached array—
each tap and type now flows the same,
from platform core to spectrum's way,
one path for all, no more the range! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: introducing iMessage typing event support and unifying event routing through a single subscribeEvent mechanism.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ryan/feat-imessage-typing

Review rate limit: 4/5 reviews remaining, refill in 12 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors Spectrum’s event subscription model to route all platform events (including messages) through a single subscribeEvent(name) entrypoint backed by a shared broadcaster-per-(platform,event) cache, and adds an iMessage typing event stream.

Changes:

  • Replace split message/custom-event subscription paths with unified PlatformRuntime.subscribeEvent(name) routing and a single eventBroadcasters map.
  • Remove Spectrum-level custom event proxy/fan-in (app.<event>), shifting non-messages consumption to platform-specific accessors (e.g. imessage(app).typing).
  • Add iMessage remote typing event stream with reconnect/backoff behavior; local mode yields an empty stream.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/spectrum-ts/src/spectrum.ts Introduces unified per-(platform,event) broadcasters and projects provider event records into [Space, payload] tuples.
packages/spectrum-ts/src/platform/types.ts Adds ProviderEventRecord/EventPayload types, tightens event typing, and replaces subscribeMessages() with subscribeEvent(name).
packages/spectrum-ts/src/platform/define.ts Generates per-platform accessors for all events via runtime.subscribeEvent(name) and caches per-event access.
packages/spectrum-ts/src/providers/imessage/remote/typing-events.ts New resilient merged typing-indicator stream for remote iMessage clients.
packages/spectrum-ts/src/providers/imessage/index.ts Wires the typing event into the iMessage platform definition (remote only).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +142 to +146
// Lazily resolve every event (including `messages`) through the runtime's
// single `subscribeEvent` entry point. Cached per-name so a second
// `for await (...of im.<event>)` reuses the broadcaster's existing
// subscription instead of opening a new upstream stream.
const eventCache = new Map<string, AsyncIterable<[Space, unknown]>>();
Comment on lines +154 to +157
if (!subscription) {
return;
}
cached = subscription;

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/spectrum-ts/src/platform/define.ts`:
- Around line 146-160: The getter for dynamic events is incorrectly caching the
subscription in eventCache so subsequent reads reuse the same AsyncIterable;
remove the per-access caching: delete the eventCache Map and its get/set calls
and change the property getter on base (the Object.defineProperty for each
eventName in def.events) to always call runtime.subscribeEvent(eventName) and
return that value (still handle a falsy/undefined return from
runtime.subscribeEvent by returning undefined). This ensures each access to
base[eventName] (e.g., imessage(app).typing) receives a fresh broadcaster
consumer.

In `@packages/spectrum-ts/src/providers/imessage/remote/typing-events.ts`:
- Around line 61-62: client.chats.subscribe() can throw before entering
runTypingSubscription, bypassing the retry/backoff and logTypingStreamError;
wrap the subscribe call in a try/catch inside the existing retry loop (the same
loop that calls runTypingSubscription) so any error from
client.chats.subscribe() is caught, forwarded to logTypingStreamError, triggers
the backoff/retry posture, and does not escape to tear the stream down. Apply
the same try/catch pattern to both occurrences where active =
client.chats.subscribe() appears, ensuring you only call runTypingSubscription
when subscribe succeeded and that caught errors loop back into the retry logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1e581d9f-c2c6-49e4-8815-bc5238280868

📥 Commits

Reviewing files that changed from the base of the PR and between 0080fc8 and c15b928.

📒 Files selected for processing (5)
  • packages/spectrum-ts/src/platform/define.ts
  • packages/spectrum-ts/src/platform/types.ts
  • packages/spectrum-ts/src/providers/imessage/index.ts
  • packages/spectrum-ts/src/providers/imessage/remote/typing-events.ts
  • packages/spectrum-ts/src/spectrum.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Agent
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Use explicit types for function parameters and return values when they enhance clarity in TypeScript
Prefer unknown over any when the type is genuinely unknown in TypeScript
Use const assertions (as const) for immutable values and literal types in TypeScript
Leverage TypeScript's type narrowing instead of type assertions

Files:

  • packages/spectrum-ts/src/providers/imessage/index.ts
  • packages/spectrum-ts/src/providers/imessage/remote/typing-events.ts
  • packages/spectrum-ts/src/platform/define.ts
  • packages/spectrum-ts/src/platform/types.ts
  • packages/spectrum-ts/src/spectrum.ts
**/*.{js,ts,jsx,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{js,ts,jsx,tsx}: Use meaningful variable names instead of magic numbers - extract constants with descriptive names
Use arrow functions for callbacks and short functions in JavaScript/TypeScript
Prefer for...of loops over .forEach() and indexed for loops in JavaScript/TypeScript
Use optional chaining (?.) and nullish coalescing (??) for safer property access in JavaScript/TypeScript
Prefer template literals over string concatenation in JavaScript/TypeScript
Use destructuring for object and array assignments in JavaScript/TypeScript
Use const by default, let only when reassignment is needed, never var in JavaScript/TypeScript
Always await promises in async functions - don't forget to use the return value in JavaScript/TypeScript
Use async/await syntax instead of promise chains for better readability in JavaScript/TypeScript
Handle errors appropriately in async code with try-catch blocks in JavaScript/TypeScript
Don't use async functions as Promise executors in JavaScript/TypeScript
Remove console.log, debugger, and alert statements from production code in JavaScript/TypeScript
Throw Error objects with descriptive messages, not strings or other values in JavaScript/TypeScript
Use try-catch blocks meaningfully - don't catch errors just to rethrow them in JavaScript/TypeScript
Prefer early returns over nested conditionals for error cases in JavaScript/TypeScript
Keep functions focused and under reasonable cognitive complexity limits
Extract complex conditions into well-named boolean variables in JavaScript/TypeScript
Use early returns to reduce nesting in JavaScript/TypeScript
Prefer simple conditionals over nested ternary operators in JavaScript/TypeScript
Group related code together and separate concerns in JavaScript/TypeScript
Don't use eval() or assign directly to document.cookie in JavaScript/TypeScript
Validate and sanitize user input in JavaScript/TypeScript
Avoid spread syntax in accumulators within loops in JavaScript/Ty...

Files:

  • packages/spectrum-ts/src/providers/imessage/index.ts
  • packages/spectrum-ts/src/providers/imessage/remote/typing-events.ts
  • packages/spectrum-ts/src/platform/define.ts
  • packages/spectrum-ts/src/platform/types.ts
  • packages/spectrum-ts/src/spectrum.ts

Comment on lines +146 to +160
const eventCache = new Map<string, AsyncIterable<[Space, unknown]>>();
for (const eventName of Object.keys(def.events)) {
if (eventName === "messages") {
continue;
}
const producer = def.events[eventName] as
| ((ctx: { client: unknown; config: unknown }) => AsyncIterable<unknown>)
| undefined;
if (producer) {
eventProperties[eventName] = producer({
client: runtime.client,
config: runtime.config,
});
}
Object.defineProperty(base, eventName, {
enumerable: true,
get() {
let cached = eventCache.get(eventName);
if (!cached) {
const subscription = runtime.subscribeEvent(eventName);
if (!subscription) {
return;
}
cached = subscription;
eventCache.set(eventName, cached);
}
return cached;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't cache the per-access event subscription.

runtime.subscribeEvent(eventName) already gives you a fresh broadcaster consumer. Caching that object here means the second imessage(app).typing read reuses the same downstream stream instead of getting its own subscription, so one closed/exhausted loop can starve later consumers.

Suggested fix
-  const eventCache = new Map<string, AsyncIterable<[Space, unknown]>>();
   for (const eventName of Object.keys(def.events)) {
     Object.defineProperty(base, eventName, {
       enumerable: true,
       get() {
-        let cached = eventCache.get(eventName);
-        if (!cached) {
-          const subscription = runtime.subscribeEvent(eventName);
-          if (!subscription) {
-            return;
-          }
-          cached = subscription;
-          eventCache.set(eventName, cached);
-        }
-        return cached;
+        return runtime.subscribeEvent(eventName);
       },
     });
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const eventCache = new Map<string, AsyncIterable<[Space, unknown]>>();
for (const eventName of Object.keys(def.events)) {
if (eventName === "messages") {
continue;
}
const producer = def.events[eventName] as
| ((ctx: { client: unknown; config: unknown }) => AsyncIterable<unknown>)
| undefined;
if (producer) {
eventProperties[eventName] = producer({
client: runtime.client,
config: runtime.config,
});
}
Object.defineProperty(base, eventName, {
enumerable: true,
get() {
let cached = eventCache.get(eventName);
if (!cached) {
const subscription = runtime.subscribeEvent(eventName);
if (!subscription) {
return;
}
cached = subscription;
eventCache.set(eventName, cached);
}
return cached;
for (const eventName of Object.keys(def.events)) {
Object.defineProperty(base, eventName, {
enumerable: true,
get() {
return runtime.subscribeEvent(eventName);
},
});
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/spectrum-ts/src/platform/define.ts` around lines 146 - 160, The
getter for dynamic events is incorrectly caching the subscription in eventCache
so subsequent reads reuse the same AsyncIterable; remove the per-access caching:
delete the eventCache Map and its get/set calls and change the property getter
on base (the Object.defineProperty for each eventName in def.events) to always
call runtime.subscribeEvent(eventName) and return that value (still handle a
falsy/undefined return from runtime.subscribeEvent by returning undefined). This
ensures each access to base[eventName] (e.g., imessage(app).typing) receives a
fresh broadcaster consumer.

Comment on lines +61 to +62
stream<IMessageTypingRecord>((emit, end) => {
let active = client.chats.subscribe();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle client.chats.subscribe() failures inside the retry loop.

Line 62 and Line 106 can throw before runTypingSubscription(...) is entered, so those failures bypass logTypingStreamError, skip backoff, and tear the stream down permanently. That breaks the resilience this helper is supposed to provide.

Suggested fix
-    let active = client.chats.subscribe();
+    let active:
+      | ReturnType<AdvancedIMessage["chats"]["subscribe"]>
+      | undefined;
@@
     const pump = (async () => {
       while (!closed) {
         try {
+          active = client.chats.subscribe();
           await runTypingSubscription(active, emit, () => {
             retryDelayMs = RECONNECT_INITIAL_DELAY_MS;
           });
         } catch (e) {
           if (!closed) {
             logTypingStreamError(e);
           }
         } finally {
-          await active.close();
+          await active?.close();
         }
 
         if (!closed) {
           await sleep(retryDelayMs);
           retryDelayMs = Math.min(retryDelayMs * 2, RECONNECT_MAX_DELAY_MS);
-          active = client.chats.subscribe();
         }
       }
       end();
     })();

As per coding guidelines, Handle errors appropriately in async code with try-catch blocks in JavaScript/TypeScript.

Also applies to: 103-106

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/spectrum-ts/src/providers/imessage/remote/typing-events.ts` around
lines 61 - 62, client.chats.subscribe() can throw before entering
runTypingSubscription, bypassing the retry/backoff and logTypingStreamError;
wrap the subscribe call in a try/catch inside the existing retry loop (the same
loop that calls runTypingSubscription) so any error from
client.chats.subscribe() is caught, forwarded to logTypingStreamError, triggers
the backoff/retry posture, and does not escape to tear the stream down. Apply
the same try/catch pattern to both occurrences where active =
client.chats.subscribe() appears, ensuring you only call runTypingSubscription
when subscribe succeeded and that caught errors loop back into the retry logic.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release Just as it is

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants