• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mobalazs / rotor-framework / 21787201423

07 Feb 2026 09:31PM UTC coverage: 89.6% (+3.7%) from 85.874%
21787201423

push

github

web-flow
Feat/generic source object data flow (#24)

* refactor: update async event handling to registerSourceObject for event routing

* test:  source object dispatchers tests

* update bsc to alpha.50

* chore: add roku-deploy as a dev dependency in package.json and package-lock.json

52 of 58 new or added lines in 4 files covered. (89.66%)

2 existing lines in 2 files now uncovered.

2171 of 2423 relevant lines covered (89.6%)

1.24 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

89.11
/src/source/RotorFrameworkTask.bs
1
' =========================================================================
2
' ▗▄▄▖  ▗▄▖▗▄▄▄▖▗▄▖ ▗▄▄▖     ▗▄▄▄▖▗▄▄▖  ▗▄▖ ▗▖  ▗▖▗▄▄▄▖▗▖ ▗▖ ▗▄▖ ▗▄▄▖ ▗▖ ▗▖
3
' ▐▌ ▐▌▐▌ ▐▌ █ ▐▌ ▐▌▐▌ ▐▌    ▐▌   ▐▌ ▐▌▐▌ ▐▌▐▛▚▞▜▌▐▌   ▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌▗▞▘
4
' ▐▛▀▚▖▐▌ ▐▌ █ ▐▌ ▐▌▐▛▀▚▖    ▐▛▀▀▘▐▛▀▚▖▐▛▀▜▌▐▌  ▐▌▐▛▀▀▘▐▌ ▐▌▐▌ ▐▌▐▛▀▚▖▐▛▚▖
5
' ▐▌ ▐▌▝▚▄▞▘ █ ▝▚▄▞▘▐▌ ▐▌    ▐▌   ▐▌ ▐▌▐▌ ▐▌▐▌  ▐▌▐▙▄▄▖▐▙█▟▌▝▚▄▞▘▐▌ ▐▌▐▌ ▐▌
6
' Rotor Framework™
7
' Version 0.7.7
8
' © 2025-2026 Balázs Molnár — Apache License 2.0
9
' =========================================================================
10

11
' constants
12
import "engine/Constants.bs"
13

14
' engine
15
import "engine/providers/DispatcherProvider.bs"
16
import "engine/providers/Dispatcher.bs"
17

18
' base classes
19
import "base/DispatcherOriginal.bs"
20
import "base/DispatcherCrossThread.bs"
21
import "base/BaseReducer.bs"
22
import "base/BaseModel.bs"
23
import "base/BaseStack.bs"
24

25
' utils
26
import "utils/GeneralUtils.bs"
27
import "utils/NodeUtils.bs"
28
import "utils/ArrayUtils.bs"
29

30
namespace Rotor
31
    ' =====================================================================
32
    ' FrameworkTask - Task thread version of Rotor Framework for MVI
33
    '
34
    ' Task thread version of the Rotor Framework that enables cross-thread MVI
35
    ' (Model-View-Intent) architecture. This class manages state and dispatchers
36
    ' on a separate task thread, allowing heavy computations and state management
37
    ' to run off the render thread for better performance.
38
    '
39
    ' Configuration:
40
    '   - tasks (array, optional): List of additional task node names to synchronize with.
41
    '                             Allows multiple task threads to communicate and share
42
    '                             dispatchers across different threads.
43
    '
44
    ' USAGE NOTES:
45
    ' The FrameworkTask must be instantiated in the task's init() function and the sync()
46
    ' method MUST be called at the end of your task function to establish the message loop.
47
    '
48
    ' IMPORTANT: The sync() method creates an infinite loop that handles cross-thread
49
    ' communication and dispatcher synchronization. This call should be the LAST statement
50
    ' in your task function, after all dispatcher initialization.
51
    '
52
    ' Example:
53
    '   File: MyTask.task.bs
54
    '   import "pkg:/source/RotorFrameworkTask.bs"
55
    '   import "pkg:/source/MyDispatcher.bs"
56
    '
57
    '   sub init()
58
    '       m.top.functionName = "task"
59
    '       m.appFw = new Rotor.FrameworkTask({
60
    '           tasks: ["AnotherTask"]
61
    '       })
62
    '   end sub
63
    '
64
    '   sub task()
65
    '       m.fooDispatcher = createFooDispatcher()
66
    '       m.barDispatcher = createBarDispatcher()
67
    '       m.appFw.sync()
68
    '   end sub
69
    ' =====================================================================
70
    class FrameworkTask
71

72
        name = "Rotor Framework"
73
        version = "0.7.7"
74

75
        config = {
76
            tasks: invalid, ' optional
77
            debug: {
78
            }
79
        }
80

81
        threadType = Rotor.Const.ThreadType.TASK
82

83
        keepAlive = true
84

85
        ' helper vars
86
        taskNode as object
87
        dispatcherProvider as object
88
        port as object
89
        sourceObjectRegistry = {} ' identity -> { dispatcherId, objectId, eventFilter }
90
        sourceObjectIdIndex = {} ' objectId -> identity (reverse index for unregistration)
91
        sourceObjectTypeRegistry = [] ' [{ dispatcherId, objectId, eventFilter }] - broadcast routing
92
        private _eventFilterFn = invalid as dynamic
93
        onTick as function
94

95
        ' ---------------------------------------------------------------------
96
        ' new - Initializes the FrameworkTask instance
97
        '
98
        ' Sets up the task thread dispatcher provider, message port, and
99
        ' global framework helper for cross-thread communication.
100
        '
101
        ' @param {object} config - Configuration object (see class documentation)
102
        '
103
        sub new(config = {} as object)
104

105
            Rotor.Utils.deepExtendAA(m.config, config)
1✔
106

107
            globalScope = GetGlobalAA()
1✔
108
            globalScope.rotor_framework_helper = { ' this give to dispatcher instance the possibility to self-register
1✔
109
                threadType: m.threadType,
110
                frameworkInstance: m
111
            }
112
            m.taskNode = globalScope.top
1✔
113

114
            m.dispatcherProvider = new Rotor.DispatcherProvider(m.threadType)
1✔
115

116
            m.taskNode.addField("rotorSync", "assocarray", true)
1✔
117
            m.port = CreateObject("roMessagePort")
1✔
118
            m.taskNode.observeFieldScoped("rotorSync", m.port)
1✔
119

120
        end sub
121

122
        ' =====================================================================
123
        ' PUBLIC API
124
        ' =====================================================================
125

126
        ' ---------------------------------------------------------------------
127
        ' getDispatcher - Gets dispatcher facade by ID
128
        '
129
        ' @param {string} dispatcherId - Dispatcher identifier
130
        ' @returns {object} Dispatcher facade instance
131
        '
132
        public function getDispatcher(dispatcherId as string) as object
133
            return m.dispatcherProvider.getFacade(dispatcherId, GetGlobalAA())
×
134
        end function
135

136
        ' ---------------------------------------------------------------------
137
        ' registerSourceObject - Registers a source object for event routing
138
        '
139
        ' Generic registry for any Roku source object. Auto-detects routing mode:
140
        '   - Identity-based: If sourceObject implements ifSourceIdentity, routes via
141
        '     GetSourceIdentity() on the event (roUrlTransfer, roChannelStore).
142
        '   - Broadcast: If sourceObject does NOT implement ifSourceIdentity, broadcasts
143
        '     to all registered dispatchers (roDeviceInfo, roInput, roAppManager).
144
        '
145
        ' @param {string} objectId - Unique identifier for this registration
146
        ' @param {string} dispatcherId - Dispatcher ID that will handle events
147
        ' @param {object} sourceObject - The source object (SetMessagePort will be called)
148
        ' @param {function} eventFilter - Optional filter function. Receives msg, returns boolean.
149
        '
150
        public sub registerSourceObject(objectId as string, dispatcherId as string, sourceObject as object, eventFilter = invalid as dynamic)
151
            sourceObject.SetMessagePort(m.port)
1✔
152
            if FindMemberFunction(sourceObject, "GetIdentity") <> invalid
3✔
153
                ' Identity-based routing
154
                identity = sourceObject.GetIdentity().ToStr()
1✔
155
                m.sourceObjectRegistry[identity] = {
1✔
156
                    dispatcherId: dispatcherId,
157
                    objectId: objectId,
158
                    eventFilter: eventFilter
159
                }
160
                m.sourceObjectIdIndex[objectId] = identity
1✔
161
            else
162
                ' Broadcast routing
3✔
163
                m.sourceObjectTypeRegistry.push({
1✔
164
                    dispatcherId: dispatcherId,
165
                    objectId: objectId,
166
                    eventFilter: eventFilter
167
                })
168
            end if
169
        end sub
170

171
        ' ---------------------------------------------------------------------
172
        ' unregisterSourceObject - Unregisters a port-based object by its objectId
173
        '
174
        ' @param {string} objectId - The unique identifier used during registration
175
        '
176
        public sub unregisterSourceObject(objectId as string)
177
            ' Try identity-based registry
178
            if m.sourceObjectIdIndex.DoesExist(objectId)
3✔
179
                identity = m.sourceObjectIdIndex[objectId]
1✔
180
                m.sourceObjectRegistry.Delete(identity)
1✔
181
                m.sourceObjectIdIndex.Delete(objectId)
1✔
182
                return
1✔
183
            end if
184
            ' Try broadcast registry
185
            for i = m.sourceObjectTypeRegistry.count() - 1 to 0 step -1
1✔
186
                if m.sourceObjectTypeRegistry[i].objectId = objectId
3✔
187
                    m.sourceObjectTypeRegistry.Delete(i)
1✔
188
                end if
189
            end for
190
        end sub
191

192
        ' ---------------------------------------------------------------------
193
        ' sync - Starts the message loop for cross-thread communication
194
        '
195
        ' IMPORTANT: This method creates an infinite loop that handles:
196
        '   - Intent dispatching from render thread
197
        '   - External dispatcher registration
198
        '   - State change notifications
199
        '   - Async reducer callbacks
200
        '
201
        ' This method MUST be the last call in your task function, as it
202
        ' blocks execution until the framework is destroyed.
203
        '
204
        sub sync(waitMs = 0 as integer, onTick = invalid as dynamic)
205
            m.notifySyncStatus(Rotor.Const.ThreadSyncType.TASK_SYNCING)
1✔
206

207
            keepAlive = true
1✔
208

209
            ' Initialize tick timer if waitMs > 0
210
            lastTickTime = invalid
1✔
211
            if waitMs > 0
3✔
212
                lastTickTime = CreateObject("roTimespan")
1✔
213
                lastTickTime.Mark()
1✔
214
            end if
215

216
            while true and keepAlive = true
1✔
217
                msg = wait(waitMs, m.port)
1✔
218

219
                if msg = invalid
2✔
220
                    ' Timeout - check if tick interval elapsed
221
                    if waitMs > 0 and onTick <> invalid and lastTickTime <> invalid
3✔
222
                        elapsedMs = lastTickTime.TotalMilliseconds()
1✔
223
                        if elapsedMs >= waitMs
3✔
224
                            ' Tick interval elapsed - call callback
225
                            Rotor.Utils.callbackScoped(onTick, GetGlobalAA())
1✔
226
                            ' Reset tick timer
227
                            lastTickTime.Mark()
1✔
228
                        end if
229
                    end if
230
                else if msg <> invalid
3✔
231
                    msgType = type(msg)
1✔
232
                    if msgType = "roSGNodeEvent"
3✔
233
                        fieldId = msg.getField()
1✔
234

235
                        if fieldId = "rotorSync"
3✔
236

237
                            sync = msg.getData() ' @type:AA
1✔
238

239
                            if sync.type = Rotor.Const.ThreadSyncType.DISPATCH
2✔
240

241

242
                                dispatcherId = sync.payload.dispatcherId
1✔
243
                                intent = sync.payload.intent
1✔
244
                                dispatcherInstance = m.dispatcherProvider.stack.LookupCI(dispatcherId)
1✔
245

246
                                ' taskIntent = Rotor.Utils.deepCopy(intent)
247
                                dispatcherInstance.dispatch(intent)
1✔
248

249
                            else if sync.type = Rotor.Const.ThreadSyncType.REGISTER_CROSS_THREAD_DISPATCHER
2✔
250

251
                                for each item in sync.crossThreadDispatcherList
1✔
252
                                    m.dispatcherProvider.registerCrossThreadDispatchers(item.dispatcherId, item.stateNode)
1✔
253
                                end for
254

255
                                m.notifySyncStatus(Rotor.Const.ThreadSyncType.TASK_SYNCED)
1✔
256

257
                            else if sync.type = Rotor.Const.ThreadSyncType.DESTROY
3✔
258

259
                                keepAlive = false
1✔
260

261
                            end if
262
                        else
NEW
263
                            ' Cross-thread state change: notify task-side listeners
×
264
                            data = msg.getData()
×
NEW
265
                            dispatcherId = fieldId
×
NEW
266
                            dispatcherInstance = m.dispatcherProvider.get(dispatcherId)
×
NEW
267
                            if dispatcherInstance <> invalid
×
UNCOV
268
                                dispatcherInstance.notifyListeners(data)
×
269
                            end if
270

271
                        end if
272
                    else
273
                        ' Generic source object routing
3✔
274
                        routed = false
1✔
275

276
                        ' Try identity-based routing
277
                        if m.sourceObjectRegistry.count() > 0
2✔
278
                            try
279
                                sourceIdentity = msg.GetSourceIdentity().ToStr()
1✔
280
                                if m.sourceObjectRegistry.DoesExist(sourceIdentity)
2✔
281
                                    entry = m.sourceObjectRegistry[sourceIdentity]
1✔
282

283
                                    ' Apply event filter if provided
284
                                    allowed = true
1✔
285
                                    if entry.eventFilter <> invalid
2✔
NEW
286
                                        m._eventFilterFn = entry.eventFilter
×
NEW
287
                                        allowed = m._eventFilterFn(msg)
×
288
                                    end if
289

290
                                    if allowed
3✔
291
                                        dispatcherInstance = m.dispatcherProvider.get(entry.dispatcherId)
1✔
292
                                        if dispatcherInstance <> invalid
3✔
293
                                            dispatcherInstance.onSourceEvent(msg)
1✔
294
                                        end if
295
                                    end if
296
                                    routed = true
1✔
297
                                end if
298
                            catch e
299
                                ' Event doesn't support GetSourceIdentity - fall through to broadcast
300
                            end try
301
                        end if
302

303
                        ' Broadcast to all non-identity registered dispatchers
304
                        if not routed
3✔
305
                            for each entry in m.sourceObjectTypeRegistry
1✔
306
                                ' Apply event filter if provided
307
                                allowed = true
1✔
308
                                if entry.eventFilter <> invalid
3✔
309
                                    m._eventFilterFn = entry.eventFilter
1✔
310
                                    allowed = m._eventFilterFn(msg)
1✔
311
                                end if
312

313
                                if allowed
3✔
314
                                    dispatcherInstance = m.dispatcherProvider.get(entry.dispatcherId)
1✔
315
                                    if dispatcherInstance <> invalid
3✔
316
                                        dispatcherInstance.onSourceEvent(msg)
1✔
317
                                    end if
318
                                end if
319
                            end for
320
                        end if
321
                    end if
322
                end if
323
            end while
324
            m.destroy()
1✔
325
        end sub
326

327
        ' =====================================================================
328
        ' INTERNAL METHODS
329
        ' =====================================================================
330

331
        ' ---------------------------------------------------------------------
332
        ' notifySyncStatus - Notifies render thread of sync status
333
        '
334
        ' Sends sync status message to render thread via rotorSync field.
335
        '
336
        ' @param {string} status - Sync status type (TASK_SYNCING or TASK_SYNCED)
337
        '
338
        sub notifySyncStatus(status as string)
339

340
            payload = {
1✔
341
                type: status,
342
                taskNode: m.taskNode
343
            }
344

345
            if status = Rotor.Const.ThreadSyncType.TASK_SYNCING
2✔
346
                payload.append({
1✔
347
                    dispatcherIds: m.dispatcherProvider.stack.Keys(),
348
                    tasks: m.config.tasks
349
                })
350
            end if
351

352
            m.taskNode.rootNode.setField("rotorSync", payload)
1✔
353

354
        end sub
355

356
        ' ---------------------------------------------------------------------
357
        ' addObserver - Adds field observer to task thread message port
358
        '
359
        ' @param {string} fieldId - Field name to observe
360
        ' @param {object} node - SceneGraph node to observe
361
        '
362
        sub addObserver(fieldId as string, node)
363
            node.observeFieldScoped(fieldId, m.port)
×
364
        end sub
365

366
        ' ---------------------------------------------------------------------
367
        ' removeObserver - Removes field observer from node
368
        '
369
        ' @param {string} fieldId - Field name to stop observing
370
        ' @param {object} node - SceneGraph node to unobserve
371
        '
372
        sub removeObserver(fieldId as string, node)
373
            node.unobserveFieldScoped(fieldId)
×
374
        end sub
375

376
        ' =====================================================================
377
        ' CLEANUP
378
        ' =====================================================================
379

380
        ' ---------------------------------------------------------------------
381
        ' destroy - Cleans up task thread resources
382
        '
383
        ' Destroys dispatcher provider and clears global framework helper.
384
        '
385
        public sub destroy()
386
            m.sourceObjectRegistry.clear()
1✔
387
            m.sourceObjectIdIndex.clear()
1✔
388
            m.sourceObjectTypeRegistry.clear()
1✔
389
            m.dispatcherProvider.destroy()
1✔
390

391
            globalScope = GetGlobalAA()
1✔
392
            globalScope.rotor_framework_helper = {
1✔
393
                frameworkInstance: invalid
394
            }
395

396
            m.taskNode.rootNode = invalid
1✔
397
            m.taskNode = invalid
1✔
398
        end sub
399

400
    end class
401

402
end namespace
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc