Skip to main content
Version: Next

TypeScript SDK

@scadex.io/sdk is the TypeScript client the dashboard itself is built on: a REST client, the /sync WebSocket with reconnection and replay routing, and React hooks. This page is for anyone building their own page or application on a gateway; React app on the SDK walks through a complete project.

What it is​

The package (version 0.0.1, license UNLICENSED) is a CommonJS build with four entry points:

ImportContents
@scadex.io/sdkScadexClient, createAuthClient, WebSocketManager, errors, formatting and core helpers, every type
@scadex.io/sdk/reactScadexProvider, useClient and the hooks
@scadex.io/sdk/coreThe tag type (UDT) parameter helpers on their own
@scadex.io/sdk/formatformatTagValue and formatScalarValue on their own

It depends on better-auth (^1.6.0) for sign-in and numerable (^0.3.15) for number formats. react and react-dom ^18.0.0 are optional peer dependencies, needed only for @scadex.io/sdk/react.

The same-origin assumption​

The SDK is written for a page served from the gateway's own origin, or one whose /api and /sync are proxied to it:

  • REST calls go to the baseUrl you give ScadexClient with fetch, and rely on the browser sending the session cookie. The SDK sets no credentials option, so cookies travel only on same-origin requests. If getAccessToken() finds a token in localStorage or sessionStorage (keys better-auth.session_token, auth:token, authjs.session-token, session) it adds Authorization: Bearer, but a cookie session stores none of these.
  • The WebSocket ignores baseUrl and always opens ws(s)://${location.host}/sync with the page's own protocol.
  • The gateway accepts the upgrade only from an origin listed in AUTH_TRUSTED_ORIGINS (see the WebSocket page).

In production, put a reverse proxy in front: the page's host serves the app and forwards /api/* and /sync to the gateway, and the gateway lists that host as a trusted origin. In development, Vite's proxy does the same:

// vite.config.ts
const gateway = process.env.GATEWAY ?? "https://app.scadex.io";
export default defineConfig({
server: {
proxy: {
"/api": { target: gateway, changeOrigin: true, secure: true },
"/sync": { target: gateway.replace(/^http/, "ws"), ws: true, changeOrigin: true, secure: true },
},
},
});

Signing in through the dev server then needs the gateway to trust http://localhost:5173 (or the port in use).

Getting it​

The package is not on npm. Build and pack it from the platform repository, then vendor the tarball:

cd platform/packages/sdk
pnpm build
pnpm pack # writes scadex.io-sdk-0.0.1.tgz
{
"dependencies": {
"@scadex.io/sdk": "file:vendor/scadex.io-sdk-0.0.1.tgz",
"react": "18.3.1",
"react-dom": "18.3.1"
}
}

Pack it again after an SDK change. This is how the NIRO operator page in the repository consumes it.

Signing in​

createAuthClient(baseUrl) returns a better-auth client bound to /api/auth on the gateway. Use it for signIn.email, getSession and signOut; the gateway's cookie does the rest. See Authentication for the routes behind it.

import { createAuthClient, ScadexClient } from "@scadex.io/sdk";

const origin = window.location.origin;
export const auth = createAuthClient(origin);
export const scadex = new ScadexClient(origin);

Two small helpers go with it: getAccessToken(): string | null and isAuthenticated(): boolean, which look only at browser storage and return null/false for a cookie session.

ScadexClient​

new ScadexClient(baseUrl: string, opts?: { debug?: boolean })

baseUrl is the gateway's origin without a trailing slash (one is stripped). debug: true logs every request to the console. One instance serves a whole application; the WebSocket connects when a ScadexProvider mounts (or when you call reconnect()) and closes when it unmounts.

REST calls time out after 30 seconds and throw ScadexApiError on a non-2xx status, with status, message (HTTP 403: <gateway message>) and body. A 204 or non-JSON response resolves to undefined.

REST groups​

Each group is a property on the client with the methods below. Routes and shapes are those of the REST API; (…) means the method takes the arguments the route needs.

GroupMethods
tagslist(serverId?), listChildren(parentPath, serverId?), get(id), create(data), update(id, data), resolve(id), restart(id), move(id, newParentPath), copy(id, {newName, targetParentPath?}), delete(id), export(path), importTags(targetPath, data), notGood(), bitMaps()
udttypes.list/get/create/update/delete/move/export/importTypes, instances.list/get/create/update/sync/delete, folders.create/rename/delete/move
alarmslist(), listForTag(tagId), create(tagId, input), get(id), update(id, input), delete(id), active(), history(query?), acknowledge(id, occurrenceId?), shelve(id, durationMs), unshelve(id)
viewslist(withBody?), get(id), create(data), update(id, data), delete(id), save(id, data), presence(id), leave(id), replaceComponents(id, components). screens is a deprecated alias
startScreenslist(all?), create(data), update(id, data), delete(id), reorder(ids), setMine({startScreenId?, openOnLaunch?})
moduleslist(), get(moduleId), start/stop/restart(moduleId), install(file, {replace?, start?, fileName?}), remove(moduleId, {purgeData?}), setLogLevel(moduleId, level), logs(moduleId, query?), logDownloadUrl(moduleId), followLogs(…)
driverslist() (running driver modules), status(moduleId), connections.list/get/create/update/delete/test/tags(moduleId, …), browse(moduleId, connectionId, parentId?, options?), discover(moduleId, connectionId, sourceIds, targetPath?, options?), createTags(moduleId, connectionId, proposals)
opc, mqttDriver-specific wrappers over drivers for the OPC UA and MQTT modules (list, get, tags, browse/browseTopic, create, update, delete, …). They return an empty list when the module is not installed
historianstatus(), points(query), buckets(query), settings(), updateSettings({retentionDays})
cronstatus(), jobs.list/get/create/update/delete/run, runs.list/get, test(job), preview(schedule, timeZone, count?)
prefsget(), update(partial)
appSettingsget(), update(partial)
userslist(), create({email, name, password, role?}), setRole(id, role), delete(id)
apiKeyslist(), create({name, role, expiresInDays?}), revoke(id)
identityProviderslist/create/update/delete/test; loginProviders() lists the public ones for a sign-in page
databases, storageengines(), list/create/update/delete/test/check; storage.list(), storage.set(kind, connectionId)
eventStreams, alarmPipelines, reports, modeslist/create/update/delete, plus test and deliveries(limit?) where the route has them, modes.activate(id)
audit, logs, diagnostics, clientsaudit.tagWrites(query?), logs.list(query?), diagnostics.get(), clients.list(), clients.subscriptions(id)
scriptConsole, restore, admin, statusscriptConsole.run(source, dryRun?), restore.status/stage/cancel, admin.checkSetup(), status.get()

alarms is replay-aware: in a replay, active() returns the replayed list, history() is clamped to the replayed moment, and acknowledge, shelve and unshelve reject with ReplayModeError.

Live values​

subscribe(path: string, handler: (msg: TagValueMessage) => void): () => void
write(path: string, value: unknown): Promise<void>
replay(path: string, start: number, end: number, maxPoints = 1000): Promise<ReplayMessage>
subscribeAlarms(handler: (frame: AlarmFrame) => void): () => void
onTagsChanged(handler: (parents: string[] | null) => void): () => void
screenEvent(input: ScreenEventInput): Promise<ScreenEventResult>
getConnectionState(): WsState // "connecting" | "connected" | "disconnected" | "error"
isConnected(): boolean
isWriting(path: string): boolean
onConnectionStateChange(fn: (state: WsState) => void): () => void
reconnect(): void
  • subscribe counts subscribers per path: ten components on one tag cost one tag.subscribe on the gateway, and a new subscriber is handed the last known value at once. The returned function unsubscribes; the last one sends tag.unsubscribe.
  • write is optimistic: subscribers immediately see the written value with pending: true, which the next server value replaces. The promise resolves on the gateway's acknowledgement and rejects with the gateway's error, WebSocket not connected, or Write timed out after 5 seconds; the optimistic value is reverted after 2 seconds without a confirming value.
  • replay is the history read (tag.replay); identical concurrent requests share one promise, and it rejects after 30 seconds.
  • subscribeAlarms sends alarm.subscribe and delivers alarm.snapshot then every alarm.* frame. applyAlarmFrame(alarms, frame) folds a frame into a list, most severe first.
  • screenEvent correlates by requestId and times out after 10 seconds.
  • The socket reconnects 3 seconds after a close, re-sends every subscription and alarm.subscribe, restarts a running replay from its last position, and calls onTagsChanged(null) so a tree can reload.

TagValueMessage is the tag.value frame plus client-side fields: {path, value, quality, timestamp, rawValue?, qualityDetail?, private?, pending?, replayed?, formattedValue?, unit?, format?}. The gateway does not set formattedValue, unit or format today.

Replay​

startReplay(options: ReplayOptions): string // {start, end?, paths?} in Unix ms; returns the replay id
pauseReplay(): void
resumeReplay(): void
stopReplay(): void // back to live
getReplay(): ReplayModeState | null
isReplaying(): boolean
now(): number // the replay's position while one is on, else Date.now()
onReplayChange(handler: (state: ReplayModeState | null) => void): () => void
onReplayNotice(handler: (notice: ReplayNotice) => void): () => void
onReplayBlocked(handler: (blocked: ReplayBlocked) => void): () => void
onReplayValue(handler: (msg: ReplayValueMessage) => void): () => void
onReplayStatus(handler: (msg: ReplayStatusMessage) => void): () => void

startReplay puts the whole client in replay mode, so no page needs to know about it:

  1. The opening state (replay.value frames with initial: true, the snapshot of alarms) is buffered until the gateway's replay.status says opened. Subscribers keep their live values until then.
  2. Then every subscriber is handed its tag's replayed value with replayed: true, and alarm listeners a replayed alarm.snapshot. A tag with no recorded value at that moment is shown as quality: "Bad" with qualityDetail: "No value was recorded for this tag at this time.".
  3. Live tag.value and alarm.* frames are held back from listeners (the last live values are still kept). @gateway.* tags other than @gateway.time, and values the gateway marked private, keep showing live.
  4. write and screenEvent reject with ReplayModeError (REPLAY_ON_MESSAGE: "Replay is on. Return to live to change values."), as do alarm acknowledgements and shelving; each refusal is reported to onReplayBlocked as {action, path?, message}. @gateway.browser.* and private paths stay writable.
  5. stopReplay hands every subscriber its live value again and re-sends alarm.subscribe.

ReplayModeState is {id, start, end, phase, position, reportedAt, sent, message, warning} with phase one of starting, playing, paused, finished, error. ReplayNotice is {kind: "refused" | "stopped-by-gateway" | "resumed", message}. GATEWAY_TIME_TAG is "@gateway.time".

React​

Import from @scadex.io/sdk/react.

Provider​

<ScadexProvider client={client}>{children}</ScadexProvider>
useClient(): { client: ScadexClient; connectionState: WsState }

ScadexProvider connects the client's WebSocket on mount and disconnects on unmount, and tracks the connection state. Mount it once, around the signed-in part of the app. useClient throws outside a provider. Every hook below reads the client from the nearest provider, or takes { client } in its options instead.

WsProvider and useWs still exist as deprecated shims around WebSocketManager; new code uses ScadexProvider.

useAuth​

useAuth(auth: { getSession: () => Promise<unknown> }): {
user: unknown; session: unknown; isLoading: boolean; isAuthenticated: boolean; error: string | null;
}

Reads the session once per auth object; pass a new object (for example from useMemo keyed on a counter) to read it again after signing in or out. user is better-auth's user object; cast it to your own shape.

useTag​

useTag<T = unknown>(path: string | null | undefined, opts?: { client?: ScadexClient }): UseTagResult<T>

Subscribes to one tag while mounted; null subscribes to nothing. The result:

FieldTypeMeaning
pathstring | nullThe path subscribed
valueT | undefinedThe live value; undefined until the gateway has sent one
qualitystring | undefinedAs the gateway says it
goodbooleantrue only for quality Good
qualityDetailstring | undefinedWhy it is not good
timestampnumber | undefinedUnix milliseconds
formattedValue, unitstring | undefinedFrom the frame, when set
loadingbooleanNo value yet
pendingbooleanA write from this client is not confirmed yet
replayedbooleanThe value comes from a replay
write(value: T) => Promise<void>Writes the tag
messageTagValueMessage | undefinedThe raw frame

useTags​

useTags(opts?: { client?: ScadexClient }): { tags: Tag[]; isLoading: boolean; error: string | null; refetch: () => Promise<void> }

Fetches the full tag list over REST (client.tags.list()) on mount.

useTagValues​

useTagValues(paths: string[], opts?: { client?: ScadexClient }): Record<string, TagValueMessage>

Subscribes to many paths and returns the latest frame per path; paths added or removed between renders are subscribed and unsubscribed. For connection status use useClient().connectionState.

useAlarms​

useAlarms(opts?: { client?: ScadexClient }): {
alarms: Alarm[]; loaded: boolean; error: unknown;
activeCount: number; unacknowledgedCount: number;
acknowledge: (alarm: Alarm) => Promise<void>;
}

Keeps the outstanding alarms live over /sync and re-reads GET /api/v1/alarms/active every 30 seconds, because a slow socket can lose frames. loaded is false until the first snapshot or read arrives. activeCount counts alarms in state active or acknowledged, unacknowledgedCount those in active or cleared; shelved alarms (isShelved(alarm)) are excluded from both but stay in alarms.

useReplay​

useReplay(opts?: { client?: ScadexClient }): ReplayModeState | null
useReplaySelector<T>(select: (state: ReplayModeState | null) => T, opts?: { client?: ScadexClient }): T

useReplay re-renders on each position report (a few times a second): use it for a clock, not a whole screen. useReplaySelector re-renders only when the selected value changes, for example useReplaySelector(s => s?.id ?? null).

Formatting​

From @scadex.io/sdk/format (also on the main entry):

formatTagValue(msg: TagValueMessage | null | undefined, tag?: Pick<Tag, "numeric"> | null, options?: FormatTagValueOptions): string
formatScalarValue(value: unknown, opts?: { formatString?: string | null; unit?: string | null }): string
BITS_FORMAT = "bits"

formatScalarValue renders booleans as TRUE/FALSE, numbers with a numerable pattern such as 0,0.00 or 0.0% (falling back to a locale default: two decimals, thousands separators from 1000, four decimals below 1), objects as a short preview like {Name: Pump, Speed: 42, …}, and appends the unit. formatTagValue picks the pattern from msg.format or tag.numeric.formatString and the unit from msg.unit or tag.numeric.engUnit. FormatTagValueOptions is {format?: boolean, unit?: boolean, fallback?: string, preferServer?: boolean}; the fallback for a missing value is —.

Core helpers​

Roles, on the main entry: ROLES (["viewer", "operator", "engineer", "admin"]), Role, parseRole(role: unknown): Role (anything unknown is viewer) and hasRole(role: Role, minimum: Role): boolean. The gateway enforces roles; a client uses these only to hide what would be refused.

Tag type (UDT) parameters, on @scadex.io/sdk/core and the main entry. The gateway's materialization and the dashboard's previews share these, so they resolve identically:

FunctionWhat it does
parseInstanceParams(raw)Turns an instance's parameters (JSON text or an object, with or without brace-wrapped keys) into Record<string, string>
normalizeParamKey(key)Strips braces: "{OPCUA}" to "OPCUA"
resolveRelativePath(rel, instanceRoot)Resolves ., ./Speed and .. against an instance's root path; absolute paths pass through
buildBuiltins(instancePath, memberKey?, rootMemberKey?)The built-in variables InstanceName, InstancePath, InstanceParent, TagName, TagPath, RootMember
substituteParams(template, ctx: SubstituteCtx)Fills {Param} and {Param|modifier} placeholders; lookup order params, builtins, paramDefaults; unresolved placeholders stay as written. Modifiers: upper, lower, trim, capitalize, title, snake, kebab, pascal, camel

Type inheritance helpers, on the main entry: effectiveMembers(type) and effectiveParameters(type) return what a type has once the types it extends are merged in (its own rows otherwise); typeIsOrExtends(type, ancestorPath, all) and descendantTypePaths(path, all) walk the parent chain. See Tag types.

Types​

The main entry exports the API's shapes as TypeScript types. The ones most pages use:

TypeMeaning
TagA tag; a union by kind (opcua, mqtt, derived, memory, expression, external, folder, udt-type, udt) over TagBase (id, path, name, parentPath, enabled, dataType, numeric, meta, UDT keys)
UdtType, UdtInstance, UdtMember, UdtParameter, UdtMemberOverrides, UdtAlarmConfigTag types and instances
Alarm, AlarmState, AlarmCondition, AlarmInput, AlarmEvent, AlarmHistoryQuery, AlarmHistoryPage, AlarmFrame, isShelved()Alarms and their /sync frames
TagValueMessage, ReplayMessage, ReplayOptions, ReplayState, ReplayStatusMessage, ReplayValueMessage, ReplayAlarmMessage, ReplayModeState, ReplayPhase, ReplayNotice, ReplayBlocked, WsStateThe WebSocket side
ScreenEventInput, ScreenEventResult, ScreenDirectives, Screen, StartScreen, StartScreenInput, StartScreenList, StartScreenRoleScreens
GatewayModule, ModuleState, ModuleLogEntry, DriverConnection, DriverConnectionInput, DriverBrowseResult, DriverTagProposal, OpcuaServer (exported as Server), MqttServerModules and drivers
User, UserPrefs, AppSettings, ApiKey, IdentityProvider, GatewayClient, DatabaseConnection, StorageRoute, Report, EventStream, AlarmPipeline, GatewayMode, CronJob, CronRun, HistorianStatusAdministration

MODULE_IDS names the built-in module ids the opc, mqtt, historian and cron groups use.

Example​

A component that shows a tag and writes it on click, inside the provider:

import { createAuthClient, ScadexClient } from "@scadex.io/sdk";
import { ScadexProvider, useAuth, useClient, useTag } from "@scadex.io/sdk/react";
import { useMemo, useState } from "react";

const origin = window.location.origin;
const auth = createAuthClient(origin);
const scadex = new ScadexClient(origin);

function RunButton() {
const run = useTag<boolean>("Line1.Pump1.Run");
const { connectionState } = useClient();
if (connectionState !== "connected") return <span>Connecting to the gateway</span>;
if (run.loading) return <span>Waiting for a value</span>;
return (
<button
disabled={run.pending || run.replayed}
onClick={() => run.write(!run.value).catch(err => alert(err.message))}
>
Pump is {run.good ? (run.value ? "running" : "stopped") : `unknown (${run.quality})`}
</button>
);
}

export function App() {
const [round, setRound] = useState(0);
const source = useMemo(() => ({ getSession: () => auth.getSession().then(r => r.data) }), [round]);
const { user, isLoading } = useAuth(source);

if (isLoading) return <p>Loading</p>;
if (!user) return <button onClick={() => auth.signIn.email({ email: "op@example.com", password: "…" }).then(() => setRound(n => n + 1))}>Sign in</button>;

return (
<ScadexProvider client={scadex}>
<RunButton />
</ScadexProvider>
);
}

The write needs the operator role; a viewer gets Your role doesn't allow writing to tags. in the rejection. The frames behind this are on the WebSocket page.