npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

@hypericon/yuzu

v0.4.2

Published

Library to push UI State updates across connected clients in CI applications

Downloads

28

Readme

Yuzu

UI State Updates -> UISU -> Yuzu

CI UIs are required to display the status of devices connected to the system in real time. This may be light levels, shade positions, monitoring alerts, etc.

Yuzu provides a mechanism for Hypericon Controller Gateways to push small amounts of realtime state to all connected clients.

Quickstart

Install the library:

npm i @hypericon/yuzu

Typescript type definitions are included.

Breaking Changes

0.4.0

  • readPath renamed to readPathExisting (readPathOptional added)
  • onChange renamed to onChangeExisting (onChangeOptional added)
  • Subscription renamed to YuzuSubscription

Shared Definition

Create the shared initial state definition object. This specifies the type of the state objects maintained in both the client and server and provides default values.

Important: The state definition object must be serialisable as JSON.

Example:

// shared/ui-state.ts

export interface ShadeStatus {
  id: string,
  statusName: string,
  position: number,
  alerts: string[],
}
export interface FixtureStatus {
  id: string,
  brightness: number,
  alerts: string[],
}

/**
 * UI state object used to initialise both client and server.
 * EITHER explicitly define key types and initial values in a constant...
 */
export const INITIAL_UI_STATE = {
  shades: [] as ShadeStatus[],
  fixtures: [] as FixtureStatus[],
} as const;

/** ... OR define a separate interface... */
export interface UiState = {
  shades: ShadeStatus[],
  fixtures: FixtureStatus[],
};
/** ... and constant. */
export const INITIAL_UI_STATE: UiState = {
  shades: [],
  fixtures: [],
};

Server Implementation

Implement the UI state server.

Either provide an existing HTTP server, or define a port on which to listen with a new server.

Example:

// server/ui-state/ui-state.service.ts

import { ServerUiState } from "@hypericon/yuzu";
import { Server } from "http";
import { INITIAL_UI_STATE, UiState, ShadeStatus } from "../../ui-state";

export class UiStateService {

  private uiState: ServerUiState;

  constructor(opts: { server: Server | undefined, port: number | undefined }) {
    this.uiState = new ServerUiState(INITIAL_UI_STATE, {
      serverRef: opts.server,
      serverConfig: opts.port ? { port: opts.port } : undefined,
    });

    // Example random position every 1,000 ms
    setTimeout(() => {
      const status: ShadeStatus = {
        id: "shadeID",
        statusName: "moving",
        position: Math.round(Math.random() * 100),
        alerts: [],
      };
      this.updateShadeStatus("shadeID", status);
    }, 1_000);
  }

  // State could be updated by listening to other parts of the application,
  // or receiving method calls from other parts of the application.

  updateShadeStatus(id: string, status: ShadeStatus) {
    const shadeIndex = this.uiState.state.shades.findIndex(s => s.id === id);

    // Simply interact with the state property of the ServerUiState object,
    // and all updates will be sent to all connected clients.
    if (shadeIndex > -1) {
      this.uiState.state.shades[shadeIndex] = status;
    } else {
      this.uiState.state.shades.push(status);
    }
  }
}

Client Implementation

Implement the UI state client. The instance of the client UI state class should be a singleton. This is not strictly necessary, but more instances will increase network traffic for no gain.

Example Mithril page component:

// client/pages/ExampleStatePage.ts

import { ClientUiState, Subscription } from "@hypericon/yuzu";
import m from "mithril";
import { UI_STATE, INITIAL_UI_STATE } from "../../ui-state";

// Client UI state singleton reference
const uiState = new ClientUiState(INITIAL_UI_STATE);

// Example Mithril page component
export const ExampleStatePage: m.Component<{}, {
  sub: Subscription,
}> = {

  oninit() {
    this.sub = new Subscription();

    // Listen to changes on the subscribable state property
    // Every object and primitive has a .subscribe() method added for precise listening
    this.sub.add(uiState.state$.shades.subscribe((shades) => console.log("shades updated")));

    // Listen to changes on ALL state keys
    this.sub.add(uiState.onAny((value, path) => {
      console.log(`State path "${path.join(".")}" updated, redrawing...`);
      m.redraw();
    }));
  },

  onremove() {
    // Clean up listeners when they are finished with
    this.sub.unsubscribe();
  },

  view() {
    return m("", [
      m("h1", "UI State Example Page"),

      // Read state using the .state property
      // The .state$ property has the same values, but reading the property ensures that .subscribe()
      // functions are set up, so can slightly decrease performance if subscribing is not immediately needed
      m("h2", "Shade Status:"),
      uiState.state.shades.map(s => {
        const alertMsg = s.alerts.length > 0 ? s.alerts.join(", ") : "no alerts";
        return m("p", `Shade ${s.id}: ${s.statusName} @${s.position}% (${alertMsg})`);
      }),

      // Read state using the .state property
      m("p", `Fixture ABC123 alerts: ${uiState.state.fixtures
                                        .filter(f => f.id === "abc123")
                                        .map(f => `${f.alerts}`)}`),
    ]);
  }

};

Considerations

Subscription Paths

In the client, any object at any depth in the state stree can be subscribed to. This includes all objects and arrays, but not primitives.

  • When the entire state tree is reloaded (for example, on network reconnection) all listeners are notified.

  • When the state tree is patched, only the listeners whose "path" is completely satisfied by the updated "path" are notified. Any "extra" segments in the updated path do not matter.

Simple example:

const sub = clientUi.state$.shades.controllers["id1"].subscribe(val => { ... });

This is a subscription to the controller with id id1 on the controllers object in the shades object in the state tree. The "path" for this listener is:

[ "shades", "controllers", "id1" ]

Server Update | Updated Path | Subscription Notified --- | --- | --- server.state.lighting.fixtures.push({ ... }); | [ "lighting", "fixtures" ] | No server.state.shades.controllers.push({ ... }); | [ "shades", "controllers" ] | No server.state.shades.controllers["id5"] = { ... }; | [ "shades", "controllers", "id5" ] | No server.state.shades.controllers["id5"].status = "Error"; | [ "shades", "controllers", "id5", "status" ] | No server.state.shades.controllers["id1"] = { ... }; | [ "shades", "controllers", "id1" ] | Yes ✓ server.state.shades.controllers["id1"].status = "Error"; | [ "shades", "controllers", "id1", "status" ] | Yes ✓ server.state.shades.controllers["id1"].shades[5].status = "Error"; | [ "shades", "controllers", "id1", "shades", "status", "5" ] | Yes ✓ server.state.shades.controllers["id1"] = undefined; | [ "shades", "controllers", "id1" ] | Yes ✓ (caution) delete server.state.shades.controllers["id1"]; | (None, no message sent) | No (no patch message sent)

Worked Example:

Given the following state interface definition:

interface State: {
  shadeControllers: {
    [id: string]: {
      status: string,
      errors: string[],
      shades: {
        status: string,
        position: number,
        errors: string[],
      }[],
    },
  },
  ...
}

const initialState: State = {
  shadeControllers: {
    id1: {
      status: "fine",
      errors: [],
      shades: [
        { status: "fine", position: 50, errors: [] },
        { status: "fine", position: 50, errors: [] },
        { status: "error", position: 50, errors: ["overtemp"] },
      ],
    },
    id2: {
      ...
    },
    ...
  },
  ...
};

And the client subscriptions:

const client = new ClientState<...>(...);

const sub1 = client.state.shadeControllers.subscribe(v => { ... });
const sub2 = client.state.shadeControllers["id1"].subscribe(v => { ... });
const sub3 = client.state.shadeControllers["id1"].shades.subscribe(v => { ... });
const sub4 = client.state.shadeControllers["id1"].shades[5].subscribe(v => { ... });

// path1 - [ "shadeControllers" ]
// path2 - [ "shadeControllers", "id1" ]
// path3 - [ "shadeControllers", "id1", "shades" ]
// path4 - [ "shadeControllers", "id1", "shades", "5" ]

Here are the results of modifying the values at the given paths:

Modifying lighting status - updated path: [ "lighting", "status" ]

  • sub1 = not triggered
  • sub2 = not triggered
  • sub3 = not triggered
  • sub4 = not triggered

Modifying controller 2 status - updated path: [ "shadeControllers", "id2", "status" ]

  • sub1 = triggered
  • sub2 = not triggered
  • sub3 = not triggered
  • sub4 = not triggered

Modifying controller 1 status - updated path: [ "shadeControllers", "id1", "status" ]

  • sub1 = triggered
  • sub2 = triggered
  • sub3 = not triggered
  • sub4 = not triggered

Modifying controller 1 shade 4 status - updated path: [ "shadeControllers", "id1", "shades", "4", "status" ]

  • sub1 = triggered
  • sub2 = triggered
  • sub3 = triggered
  • sub4 = not triggered

Modifying controller 1 shade 5 status - updated path: [ "shadeControllers", "id1", "shades", "5", "status" ]

  • sub1 = triggered
  • sub2 = triggered
  • sub3 = triggered
  • sub4 = triggered

Nullable State Keys

It may be the case that state keys be dynamically created/destroyed, for example for transient pieces of equipment or pieces of state. It may be desireable to access the state regarding these by some key or ID.

As shown above, the state object can be defined as such to support this case:

export interface UiState {
  ...
  transientDevices: {
    [id: string]: DeviceState | undefined,
  },
  ...
}
export interface DeviceState {
  status: string,
  ...
}

Note the union type.

This can be written to from the server like so:

uiState.state.transientDevices["device1"] = { ... }; // Set device to initial value
uiState.state.transientDevices["device1"] = { ... }; // Overwrite device value
uiState.state.transientDevices["device1"].status = " ... "; // Update single key in device
uiState.state.transientDevices["device1"] = undefined; // Remove device

And listened to from the client like so:

// Receives all updates
uiState.state$.transientDevices.subscribe(
  (allDevicesObj: { [id: string]: DeviceState | undefined }) => { ... },
);
// Receives all updates, on the last update the parameter is undefined
uiState.state$.transientDevices["device1"].subscribe(device: DeviceState | undefined => { ... });
// Receives one update, when the status is explicitly updated
uiState.state$.transientDevices["device1"].status.subscribe(status: string => { ... });

Arrays in State Tree

Arrays are fully supported, as are all the mutation methods (e.g. push, splice, etc.).

However, due to the way they are updated internally, updates can end up being relatively "chatty" compared to objects.

For example, the push operation below:

server.state.myArray = [1, 2, 3, 4, 5];
server.state.myArray.push(10, 11);

results in three patches:

[ "myArray", "5" ]: undefined => 10
[ "myArray", "6" ]: undefined => 11
[ "myArray", "length" ]: 5 => 7

Development

git clone https://github.com/hypcn/yuzu
cd yuzu
npm i
npm run dev

This starts a hot-reloading example of both client and server usage, and opens the client in the default browser.