Repository: SignalK/signalk-server
Pull request #1945: fix: fix streambundle error after port to TypeScript
Author: login: bkeepers
Labels: name: chore
Created: 2025-04-16T13:23:14Z | Merged: 2025-04-24T18:05:03Z

This fixes an [error reported](https://discord.com/channels/1170433917761892493/1360533803533340704/1361802762987372614) by @KEGustafsson introduced in #1918. I have not been able to recreate it, but apparently there are instances where `update.values` is present, but is not an array. Looking back at #1918, it looks like the old code [did have an `if` conditional](https://github.com/SignalK/signalk-server/pull/1918/files#diff-fb03df6f129b4ff2d3608e512e7ca174c9d92d6fe93307c984c730b45903889bL38), so there must be instances where it is `null`/`undefined`. ~~I updated the types to be consistent with the implementation as well.~~

While I was looking at that, I noticed that `app` is injected into stream bundle, but is unused, so I removed it in another commit.


Diff:
diff --git a/packages/server-api/src/serverapi.ts b/packages/server-api/src/serverapi.ts
index f3ab5c939..1783172e9 100644
--- a/packages/server-api/src/serverapi.ts
+++ b/packages/server-api/src/serverapi.ts
@@ -422,8 +422,8 @@ export interface ServerAPI
 export type PluginServerApp = ServerAPI
 
 export type DeltaInputHandler = (
-  delta: object,
-  next: (delta: object) => void
+  delta: Delta,
+  next: (delta: Delta) => void
 ) => void
 
 export interface Ports {
diff --git a/src/deltachain.ts b/src/deltachain.ts
index a2f6032c6..3057eb8db 100644
--- a/src/deltachain.ts
+++ b/src/deltachain.ts
@@ -1,6 +1,6 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
 
-import { DeltaInputHandler } from '@signalk/server-api'
+import { Delta, DeltaInputHandler } from '@signalk/server-api'
 
 export default class DeltaChain {
   chain: any
@@ -10,7 +10,7 @@ export default class DeltaChain {
     this.next = []
   }
 
-  process(msg: any) {
+  process(msg: Delta) {
     return this.doProcess(0, msg)
   }
 
diff --git a/src/index.ts b/src/index.ts
index 9efe4122e..50993ec37 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -269,7 +269,7 @@ class Server {
       data: Partial<Delta>,
       skVersion = SKVersion.v1
     ) => {
-      if (data && data.updates) {
+      if (data && Array.isArray(data.updates)) {
         incDeltaStatistics(app, providerId)
 
         if (
@@ -279,21 +279,41 @@ class Server {
           data.context = ('vessels.' + app.selfId) as Context
         }
         const now = new Date()
-        data.updates.forEach((update: Update) => {
-          if (typeof update.source !== 'undefined') {
-            update.source.label = providerId
-            if (!update.$source) {
-              update.$source = getSourceId(update.source)
+        data.updates = data.updates
+          .map((update: Partial<Update>) => {
+            if (typeof update.source !== 'undefined') {
+              update.source.label = providerId
+              if (!update.$source) {
+                update.$source = getSourceId(update.source)
+              }
+            } else {
+              if (typeof update.$source === 'undefined') {
+                update.$source = providerId as SourceRef
+              }
             }
-          } else {
-            if (typeof update.$source === 'undefined') {
-              update.$source = providerId as SourceRef
+            if (!update.timestamp || app.config.overrideTimestampWithNow) {
+              update.timestamp = now.toISOString() as Timestamp
             }
-          }
-          if (!update.timestamp || app.config.overrideTimestampWithNow) {
-            update.timestamp = now.toISOString() as Timestamp
-          }
-        })
+
+            if ('values' in update && !Array.isArray(update.values)) {
+              debug(`handleMessage: ignoring invalid values`, update.values)
+              delete update.values
+            }
+
+            if ('meta' in update && !Array.isArray(update.meta)) {
+              debug(`handleMessage: ignoring invalid meta`, update.meta)
+              delete update.meta
+            }
+
+            if ('values' in update || 'meta' in update) {
+              return update as Update
+            }
+          })
+          .filter((update) => update !== undefined)
+
+        // No valid updates, discarding
+        if (data.updates.length < 1) return
+
         try {
           const preferredDelta = toPreferredDelta(data, now, app.selfContext)
           if (skVersion == SKVersion.v1) {
@@ -307,7 +327,7 @@ class Server {
       }
     }
 
-    app.streambundle = new StreamBundle(app, app.selfId)
+    app.streambundle = new StreamBundle(app.selfId)
     new Zones(app.streambundle, (delta: Delta) =>
       app.handleMessage('self.notificationhandler', delta)
     )
diff --git a/src/interfaces/plugins.ts b/src/interfaces/plugins.ts
index d673a25cd..89a1b774f 100644
--- a/src/interfaces/plugins.ts
+++ b/src/interfaces/plugins.ts
@@ -29,7 +29,8 @@ import {
   SourceRef,
   PluginConstructor,
   Plugin,
-  Path
+  Path,
+  Delta
 } from '@signalk/server-api'
 import { getLogger } from '@signalk/streams/logging'
 import express, { Request, Response } from 'express'
@@ -489,7 +490,7 @@ module.exports = (theApp: any) => {
         }
       },
       debug: createDebug(packageName),
-      registerDeltaInputHandler: (handler: any) => {
+      registerDeltaInputHandler: (handler: Delta) => {
         onStopHandlers[plugin.id].push(app.registerDeltaInputHandler(handler))
       },
       setProviderStatus: deprecate((msg: string) => {
diff --git a/src/streambundle.ts b/src/streambundle.ts
index 6fd24d22f..788123281 100644
--- a/src/streambundle.ts
+++ b/src/streambundle.ts
@@ -19,7 +19,6 @@ import {
   Delta,
   NormalizedDelta,
   Path,
-  ServerAPI,
   Update,
   Value,
   NormalizedMetaDelta
@@ -36,11 +35,10 @@ export class StreamBundle implements IStreamBundle {
   selfAllPathsStream: Bacon.Bus<unknown, Value>
   keys: Bacon.Bus<unknown, Path>
   availableSelfPaths: { [key: Path]: true }
-  app: ServerAPI
   metaBus: Bacon.Bus<unknown, NormalizedMetaDelta>
   selfMetaBus: Bacon.Bus<unknown, NormalizedMetaDelta>
 
-  constructor(app: ServerAPI, selfId: string) {
+  constructor(selfId: string) {
     this.selfContext = 'vessels.' + selfId
     this.buses = {}
     this.allPathsBus = new Bacon.Bus()
@@ -50,7 +48,6 @@ export class StreamBundle implements IStreamBundle {
     this.selfAllPathsStream = new Bacon.Bus()
     this.keys = new Bacon.Bus()
     this.availableSelfPaths = {}
-    this.app = app
     this.metaBus = new Bacon.Bus()
     this.selfMetaBus = new Bacon.Bus()
   }
@@ -87,7 +84,7 @@ export class StreamBundle implements IStreamBundle {
               })
             })
           }
-        }, this)
+        })
       }
     } catch (e) {
       console.error(e)


Reviews:
user: login: bkeepers
body: > I can’t think of a use case for values not being an array, so i would rather implement a check-and-discard in handleMessage so that downstream one can expect valid data. 

Sounds good, I updated `handleMessage`.  I tried adding some tests for this behavior, but adding unit tests was going to require some refactoring that belongs in a pull request of its own. 
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T21:39:59Z
user: login: tkurki
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-19T06:15:13Z
user: login: tkurki
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T17:42:38Z
user: login: bkeepers
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T21:14:40Z

Review comments:
diff_hunk: @@ -279,21 +279,41 @@ class Server {
           data.context = ('vessels.' + app.selfId) as Context
         }
         const now = new Date()
-        data.updates.forEach((update: Update) => {
-          if (typeof update.source !== 'undefined') {
-            update.source.label = providerId
-            if (!update.$source) {
-              update.$source = getSourceId(update.source)
+        data.updates = data.updates
+          .map((update: Partial<Update>) => {
+            if (typeof update.source !== 'undefined') {
+              update.source.label = providerId
+              if (!update.$source) {
+                update.$source = getSourceId(update.source)
+              }
+            } else {
+              if (typeof update.$source === 'undefined') {
+                update.$source = providerId as SourceRef
+              }
             }
-          } else {
-            if (typeof update.$source === 'undefined') {
-              update.$source = providerId as SourceRef
+            if (!update.timestamp || app.config.overrideTimestampWithNow) {
+              update.timestamp = now.toISOString() as Timestamp
             }
-          }
-          if (!update.timestamp || app.config.overrideTimestampWithNow) {
-            update.timestamp = now.toISOString() as Timestamp
-          }
-        })
+
+            if ('values' in update && !Array.isArray(update.values)) {
+              debug(`handleMessage: ignoring invalid values`, update.values)
+              delete update.values
+            }
+
+            if ('meta' in update && !Array.isArray(update.meta)) {
+              debug(`handleMessage: ignoring invalid meta`, update.meta)
+              delete update.meta
+            }
+
+            if ('values' in update || 'meta' in update) {
+              return update as Update
+            }
+          })
+          .filter((update) => update !== undefined)
+
+        // No valid updates, discarding
+        if (data.updates.length < 1) return
+
path: src/index.ts
user: login: bkeepers
body: Prettier's 80 char line limit is so annoying. Most of these changes are because line 282 is now just over 80 characters, and now it's hard to see what else I actually changed. Anyone opposed to changing it to 100 characters?

Here's a link to [view the diff without whitespace changes](https://github.com/SignalK/signalk-server/pull/1945/files?w=1)
created_at: 2025-04-16T21:05:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -279,21 +279,41 @@ class Server {
           data.context = ('vessels.' + app.selfId) as Context
         }
         const now = new Date()
-        data.updates.forEach((update: Update) => {
-          if (typeof update.source !== 'undefined') {
-            update.source.label = providerId
-            if (!update.$source) {
-              update.$source = getSourceId(update.source)
+        data.updates = data.updates
+          .map((update: Partial<Update>) => {
+            if (typeof update.source !== 'undefined') {
+              update.source.label = providerId
+              if (!update.$source) {
+                update.$source = getSourceId(update.source)
+              }
+            } else {
+              if (typeof update.$source === 'undefined') {
+                update.$source = providerId as SourceRef
+              }
             }
-          } else {
-            if (typeof update.$source === 'undefined') {
-              update.$source = providerId as SourceRef
+            if (!update.timestamp || app.config.overrideTimestampWithNow) {
+              update.timestamp = now.toISOString() as Timestamp
             }
-          }
-          if (!update.timestamp || app.config.overrideTimestampWithNow) {
-            update.timestamp = now.toISOString() as Timestamp
-          }
-        })
+
+            if ('values' in update && !Array.isArray(update.values)) {
path: src/index.ts
user: login: tkurki
body: How about documenting what this does / why it's here by extracting something like `hasInvalidValues` and ditto for meta?
created_at: 2025-04-19T06:15:13Z
author_association: MEMBER
diff_hunk: @@ -279,21 +279,41 @@ class Server {
           data.context = ('vessels.' + app.selfId) as Context
         }
         const now = new Date()
-        data.updates.forEach((update: Update) => {
-          if (typeof update.source !== 'undefined') {
-            update.source.label = providerId
-            if (!update.$source) {
-              update.$source = getSourceId(update.source)
+        data.updates = data.updates
+          .map((update: Partial<Update>) => {
+            if (typeof update.source !== 'undefined') {
+              update.source.label = providerId
+              if (!update.$source) {
+                update.$source = getSourceId(update.source)
+              }
+            } else {
+              if (typeof update.$source === 'undefined') {
+                update.$source = providerId as SourceRef
+              }
             }
-          } else {
-            if (typeof update.$source === 'undefined') {
-              update.$source = providerId as SourceRef
+            if (!update.timestamp || app.config.overrideTimestampWithNow) {
+              update.timestamp = now.toISOString() as Timestamp
             }
-          }
-          if (!update.timestamp || app.config.overrideTimestampWithNow) {
-            update.timestamp = now.toISOString() as Timestamp
-          }
-        })
+
+            if ('values' in update && !Array.isArray(update.values)) {
path: src/index.ts
user: login: tkurki
body: Scratch this - without the `'values'  in update` inline here the values references in the following lines don't work
created_at: 2025-04-24T17:42:38Z
author_association: MEMBER
diff_hunk: @@ -279,21 +279,41 @@ class Server {
           data.context = ('vessels.' + app.selfId) as Context
         }
         const now = new Date()
-        data.updates.forEach((update: Update) => {
-          if (typeof update.source !== 'undefined') {
-            update.source.label = providerId
-            if (!update.$source) {
-              update.$source = getSourceId(update.source)
+        data.updates = data.updates
+          .map((update: Partial<Update>) => {
+            if (typeof update.source !== 'undefined') {
+              update.source.label = providerId
+              if (!update.$source) {
+                update.$source = getSourceId(update.source)
+              }
+            } else {
+              if (typeof update.$source === 'undefined') {
+                update.$source = providerId as SourceRef
+              }
             }
-          } else {
-            if (typeof update.$source === 'undefined') {
-              update.$source = providerId as SourceRef
+            if (!update.timestamp || app.config.overrideTimestampWithNow) {
+              update.timestamp = now.toISOString() as Timestamp
             }
-          }
-          if (!update.timestamp || app.config.overrideTimestampWithNow) {
-            update.timestamp = now.toISOString() as Timestamp
-          }
-        })
+
+            if ('values' in update && !Array.isArray(update.values)) {
path: src/index.ts
user: login: bkeepers
body: Yeah, I looked at fixing this the other day and ran into the same issue, then I forgot to come back to it.
created_at: 2025-04-24T21:14:40Z
author_association: CONTRIBUTOR

PR comments:
user: login: tkurki
created_at: 2025-04-16T15:05:47Z
body: I can’t think of a use case for `values` not being an array, so i would rather implement a check-and-discard in handleMessage so that downstream one can expect valid data. @panaaj @sbender9 @KEGustafsson ?
author_association: MEMBER
user: login: sbender9
created_at: 2025-04-16T15:14:05Z
body: Just don't forget about meta deltas, where "values" may be undefined. 
author_association: MEMBER
user: login: KEGustafsson
created_at: 2025-04-16T17:04:18Z
body: This and #1942 seemed to fix recently seen problems.
I could try to spend couple of minutes to check what actually caused issues.
author_association: COLLABORATOR
user: login: KEGustafsson
created_at: 2025-04-16T17:20:07Z
body: It is meta causing this issue . Added try-catch to collect deltas and here is small piece of the log throwing error.
```
Apr 16 17:23:34 { "meta": [ { "path": "notifications.mob", "value": { "supportsPut": true } } ], "$source": "freeboard-sk", "timestamp": "2025-04-16T17:23:34.205Z" }
Apr 16 17:23:34 { "meta": [ { "path": "notifications.fire", "value": { "supportsPut": true } } ], "$source": "freeboard-sk", "timestamp": "2025-04-16T17:23:34.209Z" }
Apr 16 17:23:34 { "meta": [ { "path": "notifications.sinking", "value": { "supportsPut": true } } ], "$source": "freeboard-sk", "timestamp": "2025-04-16T17:23:34.212Z" }
Apr 16 17:23:34 { "meta": [ { "path": "notifications.flooding", "value": { "supportsPut": true } } ], "$source": "freeboard-sk", "timestamp": "2025-04-16T17:23:34.214Z" }
Apr 16 17:23:34 { "meta": [ { "path": "notifications.collision", "value": { "supportsPut": true } } ], "$source": "freeboard-sk", "timestamp": "2025-04-16T17:23:34.216Z" }
Apr 16 17:23:34 { "meta": [ { "path": "notifications.grounding", "value": { "supportsPut": true } } ], "$source": "freeboard-sk", "timestamp": "2025-04-16T17:23:34.218Z" }
Apr 16 17:23:34 { "meta": [ { "path": "notifications.listing", "value": { "supportsPut": true } } ], "$source": "freeboard-sk", "timestamp": "2025-04-16T17:23:34.219Z" }
```
author_association: COLLABORATOR