Skip to content

Commit

Permalink
Handle room.subscribed in realtime-api package (#311)
Browse files Browse the repository at this point in the history
* ask get_initial_state on room subscribe

* applyEmitterTransforms before subscribe

* update toExternalJSON to convert nested arrays

* add toExternalJSON test case

* change ConsumerContract to accept a return type for subscribe

* handle room.subscribed for realtime-api

* update rt example

* update specs

* export interface and update docstring for subscribe()

* add changeset

* Add `instanceProxyFactory` (#315)

* update toExternalJSON to be no-op for no-snake-case strings

* add EventTransformType on each EventTransform

* welcome proxyFactory

* update BaseComponent to process the event payload for nested fields

* rename proxyFactory to eventTransformUtils and refactoring a bit

* add instanceProxyFactory tests

* add comment for EventTransformType

* add comment on _instanceByTransformKey

* use type instead of key for EventTransformType

* add comment

* add comments to explain why we override subscribe on RoomSession

* fix convert to date including ms

* update changeset

* rename key to type in test comments
  • Loading branch information
Edoardo Gallo authored Oct 4, 2021
1 parent cc5fd62 commit febb842
Show file tree
Hide file tree
Showing 19 changed files with 466 additions and 31 deletions.
6 changes: 6 additions & 0 deletions .changeset/stale-insects-happen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@signalwire/core': patch
---

Update `ConsumerContract` interface and add array-keys to the toExternalJSON whitelist.
Fix issue on converting timestamp values to `Date` objects. [realtime-api]
5 changes: 5 additions & 0 deletions .changeset/tidy-ducks-dress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@signalwire/realtime-api': patch
---

Allow users to listen the `room.subscribed` event and change the `roomSession.subscribe()` to return a `Promise<RoomSessionFullState>`.
14 changes: 12 additions & 2 deletions packages/core/src/BaseComponent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ describe('BaseComponent', () => {
[
['video.jest.snake_case', 'video.jest.camel_case'],
{
type: 'roomSession' as const,
instanceFactory: () => {
return {
instance: this,
Expand Down Expand Up @@ -230,27 +231,30 @@ describe('BaseComponent', () => {
const mockPayloadTransformLocal = jest.fn()

const localEventName = toLocalEvent('video.jest.localEvent')

const eventTransformKey = 'roomSession' as const
class CustomComponent extends JestComponent {
protected getEmitterTransforms() {
return new Map([
[
['video.jest.eventOne', 'video.jest.eventTwo'],
{
type: eventTransformKey,
instanceFactory: mockInstanceFactoryRegistered,
payloadTransform: mockPayloadTransformRegistered,
},
],
[
['video.jest.notRegistered'],
{
type: eventTransformKey,
instanceFactory: mockInstanceFactoryNotRegistered,
payloadTransform: mockPayloadTransformNotRegistered,
},
],
[
[localEventName],
{
type: eventTransformKey,
instanceFactory: mockInstanceFactoryLocal,
payloadTransform: mockPayloadTransformLocal,
},
Expand Down Expand Up @@ -280,6 +284,10 @@ describe('BaseComponent', () => {
expect(mockInstanceFactoryNotRegistered).toHaveBeenCalledTimes(0)
expect(mockPayloadTransformNotRegistered).toHaveBeenCalledTimes(0)

/** 2 transforms because we added the transform `type` too */
// @ts-expect-error
expect(instance._emitterTransforms.size).toEqual(2)

// @ts-expect-error
instance.applyEmitterTransforms()
instance.emit(localEventName, {})
Expand All @@ -295,8 +303,10 @@ describe('BaseComponent', () => {
expect(mockPayloadTransformRegistered).toHaveBeenCalledTimes(1)
expect(mockInstanceFactoryNotRegistered).toHaveBeenCalledTimes(0)
expect(mockPayloadTransformNotRegistered).toHaveBeenCalledTimes(0)

/** 3 transforms because we added the transform `type` too */
// @ts-expect-error
expect(instance._emitterTransforms.size).toEqual(2)
expect(instance._emitterTransforms.size).toEqual(3)
})
})
})
37 changes: 35 additions & 2 deletions packages/core/src/BaseComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
toInternalEventName,
isLocalEvent,
validateEventsToSubscribe,
instanceProxyFactory,
NESTED_FIELDS_TO_PROCESS,
} from './utils'
import { executeAction } from './redux'
import {
Expand All @@ -13,6 +15,7 @@ import {
BaseComponentOptions,
ExecuteExtendedOptions,
EventsPrefix,
EventTransformType,
EventTransform,
} from './utils/interfaces'
import { EventEmitter } from './utils/EventEmitter'
Expand Down Expand Up @@ -122,7 +125,7 @@ export class BaseComponent<
* interface).
*/
private _emitterTransforms: Map<
EventEmitter.EventNames<EventTypes>,
EventEmitter.EventNames<EventTypes> | EventTransformType,
EventTransform
> = new Map()

Expand Down Expand Up @@ -311,7 +314,9 @@ export class BaseComponent<
payload,
})

const transformedPayload = transform.payloadTransform(payload)
const transformedPayload = this._parseNestedFields(
transform.payloadTransform(payload)
)
const proxiedObj = new Proxy(cachedInstance, {
get(target: any, prop: any, receiver: any) {
if (
Expand Down Expand Up @@ -341,6 +346,26 @@ export class BaseComponent<
>
}

private _parseNestedFields(transformedPayload: any) {
NESTED_FIELDS_TO_PROCESS.forEach(
({ field, preProcessPayload, eventTransformType }) => {
const transform = this._emitterTransforms.get(eventTransformType)
if (!transform || !transformedPayload?.[field]?.length) {
return
}
transformedPayload[field] = transformedPayload[field].map(
(jsonPayload: any) => {
return instanceProxyFactory({
transform,
payload: preProcessPayload(jsonPayload),
})
}
)
}
)
return transformedPayload
}

private getOrCreateStableEventHandler(
internalEvent: EventEmitter.EventNames<EventTypes>,
fn: EventEmitter.EventListener<
Expand Down Expand Up @@ -699,6 +724,14 @@ export class BaseComponent<
local,
})
}

/**
* Set a transform using the `key` to select it easily when
* creating Proxy objects.
* The transform by `type` will be used by nested fields while the top-level
* by `internalEvent` for each single event transform.
*/
this._emitterTransforms.set(handlersObj.type, handlersObj)
})
}
}
5 changes: 3 additions & 2 deletions packages/core/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,10 @@ export interface BaseConnectionContract<
}

export interface ConsumerContract<
EventTypes extends EventEmitter.ValidEventTypes
EventTypes extends EventEmitter.ValidEventTypes,
SubscribeType = void
> extends EmitterContract<EventTypes> {
subscribe(): Promise<void>
subscribe(): Promise<SubscribeType>
}

export interface ClientContract<
Expand Down
86 changes: 86 additions & 0 deletions packages/core/src/utils/eventTransformUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { EventTransform } from './interfaces'
import {
instanceProxyFactory,
_instanceByTransformType,
} from './eventTransformUtils'
import { toExternalJSON } from './toExternalJSON'

describe('instanceProxyFactory', () => {
const mockInstance = jest.fn()

const payload = {
nested: {
id: 'random',
snake_case: 'foo',
camelCase: 'baz',
otherTest: true,
counter: 0,
},
}

const transform: EventTransform = {
// @ts-expect-error
type: 'randomKey',
instanceFactory: () => {
return mockInstance
},
payloadTransform: (payload: any) => {
return toExternalJSON(payload.nested)
},
getInstanceEventNamespace: (payload: any) => {
return payload.nested.id
},
getInstanceEventChannel: (payload: any) => {
return payload.nested.snake_case
},
}

it('should return a cached Proxy object reading from the payload', () => {
for (let i = 0; i < 4; i++) {
const proxy = instanceProxyFactory({
transform,
payload: {
nested: {
...payload.nested,
counter: i,
},
},
})

expect(proxy.snakeCase).toBe('foo')
expect(proxy.snake_case).toBeUndefined()
expect(proxy.camelCase).toBe('baz')
expect(proxy.otherTest).toBe(true)
expect(proxy.counter).toBe(i)
expect(proxy.eventChannel).toBe('foo')
expect(proxy._eventsNamespace).toBe('random')
}

expect(_instanceByTransformType.size).toBe(1)
expect(_instanceByTransformType.get('randomKey')).toBe(mockInstance)
})

it('should cache the instances by type', () => {
const firstProxy = instanceProxyFactory({ transform, payload })
expect(firstProxy.snakeCase).toBe('foo')

const secondProxy = instanceProxyFactory({ transform, payload })
expect(secondProxy.snakeCase).toBe('foo')

expect(_instanceByTransformType.size).toBe(1)
expect(_instanceByTransformType.get('randomKey')).toBe(mockInstance)

const thirdProxy = instanceProxyFactory({
transform: {
...transform,
// @ts-expect-error
type: 'otherKey',
},
payload,
})
expect(thirdProxy.snakeCase).toBe('foo')

expect(_instanceByTransformType.size).toBe(2)
expect(_instanceByTransformType.get('otherKey')).toBe(mockInstance)
})
})
86 changes: 86 additions & 0 deletions packages/core/src/utils/eventTransformUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { EventTransform, EventTransformType } from './interfaces'

interface InstanceProxyFactoryParams {
transform: EventTransform
payload: Record<any, unknown>
}

interface NestedFieldToProcess {
/** Nested field to transform through an EventTransform */
field: string
/**
* Allow us to update the nested `payload` to match the shape we already
* treat consuming other events from the server.
* For example: wrapping the `payload` within a specific key.
* `payload` becomes `{ "member": payload }`
*/
preProcessPayload: (payload: any) => any
/** Type of the EventTransform to select from `instance._emitterTransforms` */
eventTransformType: EventTransformType
}

/**
* Note: the cached instances within `_instanceByTransformType` will never be
* cleaned since we're caching by `transform.type` so we will always have one
* instance per type regardless of the Room/Member/Recording we're working on.
* This is something we can improve in the future, but not an issue right now.
* Exported for test purposes
*/
export const _instanceByTransformType = new Map<string, EventTransform>()
export const NESTED_FIELDS_TO_PROCESS: NestedFieldToProcess[] = [
{
field: 'members',
preProcessPayload: (payload) => ({ member: payload }),
eventTransformType: 'roomSessionMember',
},
{
field: 'recordings',
preProcessPayload: (payload) => ({ recording: payload }),
eventTransformType: 'roomSessionRecording',
},
]

const _getOrCreateInstance = ({
transform,
payload,
}: InstanceProxyFactoryParams) => {
if (!_instanceByTransformType.has(transform.type)) {
const instance = transform.instanceFactory(payload)
_instanceByTransformType.set(transform.type, instance)

return instance
}

return _instanceByTransformType.get(transform.type)
}

export const instanceProxyFactory = ({
transform,
payload,
}: InstanceProxyFactoryParams) => {
/** Create the instance or pick from cache */
const cachedInstance = _getOrCreateInstance({
transform,
payload,
})

const transformedPayload = transform.payloadTransform(payload)
const proxiedObj = new Proxy(cachedInstance, {
get(target: any, prop: any, receiver: any) {
if (prop === '_eventsNamespace' && transform.getInstanceEventNamespace) {
return transform.getInstanceEventNamespace(payload)
}
if (prop === 'eventChannel' && transform.getInstanceEventChannel) {
return transform.getInstanceEventChannel(payload)
}

if (prop in transformedPayload) {
return transformedPayload[prop]
}

return Reflect.get(target, prop, receiver)
},
})

return proxiedObj
}
1 change: 1 addition & 0 deletions packages/core/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export * from './parseRPCResponse'
export * from './toExternalJSON'
export * from './toInternalEventName'
export * from './extendComponent'
export * from './eventTransformUtils'

export const mutateStorageKey = (key: string) => `${STORAGE_PREFIX}${key}`

Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/utils/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,22 @@ export type GlobalVideoEvents = typeof GLOBAL_VIDEO_EVENTS[number]
export type InternalGlobalVideoEvents =
typeof INTERNAL_GLOBAL_VIDEO_EVENTS[number]

/**
* NOTE: `EventTransformType` is not tied to a constructor but more on
* the event payloads.
* We are using `roomSession` and `roomSessionSubscribed` here because
* some "Room" events have similar payloads while `room.subscribed` has
* nested fields the SDK has to process.
* `EventTransformType` identifies a unique `EventTransform` type based on the
* payload it has to process.
*/
export type EventTransformType =
| 'roomSession'
| 'roomSessionSubscribed'
| 'roomSessionMember'
| 'roomSessionLayout'
| 'roomSessionRecording'
| 'roomSessionPlayback'
/**
* `EventTransform`s represent our internal pipeline for
* creating specific instances for each event handler. This
Expand Down Expand Up @@ -284,6 +300,11 @@ export type InternalGlobalVideoEvents =
* └───────────────────────────────────┘
*/
export interface EventTransform {
/**
* Using the `key` we can cache and retrieve a single instance
* for the **stateless** object returned by `instanceFactory`
*/
type: EventTransformType
/**
* Must return an **stateless** object. Think of it as a
* set of APIs representing the behavior you want to
Expand Down
Loading

0 comments on commit febb842

Please sign in to comment.