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:
| Import | Contents |
|---|---|
@scadex.io/sdk | ScadexClient, createAuthClient, WebSocketManager, errors, formatting and core helpers, every type |
@scadex.io/sdk/react | ScadexProvider, useClient and the hooks |
@scadex.io/sdk/core | The tag type (UDT) parameter helpers on their own |
@scadex.io/sdk/format | formatTagValue 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
baseUrlyou giveScadexClientwithfetch, and rely on the browser sending the session cookie. The SDK sets nocredentialsoption, so cookies travel only on same-origin requests. IfgetAccessToken()finds a token inlocalStorageorsessionStorage(keysbetter-auth.session_token,auth:token,authjs.session-token,session) it addsAuthorization: Bearer, but a cookie session stores none of these. - The WebSocket ignores
baseUrland always opensws(s)://${location.host}/syncwith 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.
| Group | Methods |
|---|---|
tags | list(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() |
udt | types.list/get/create/update/delete/move/export/importTypes, instances.list/get/create/update/sync/delete, folders.create/rename/delete/move |
alarms | list(), listForTag(tagId), create(tagId, input), get(id), update(id, input), delete(id), active(), history(query?), acknowledge(id, occurrenceId?), shelve(id, durationMs), unshelve(id) |
views | list(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 |
startScreens | list(all?), create(data), update(id, data), delete(id), reorder(ids), setMine({startScreenId?, openOnLaunch?}) |
modules | list(), get(moduleId), start/stop/restart(moduleId), install(file, {replace?, start?, fileName?}), remove(moduleId, {purgeData?}), setLogLevel(moduleId, level), logs(moduleId, query?), logDownloadUrl(moduleId), followLogs(…) |
drivers | list() (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, mqtt | Driver-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 |
historian | status(), points(query), buckets(query), settings(), updateSettings({retentionDays}) |
cron | status(), jobs.list/get/create/update/delete/run, runs.list/get, test(job), preview(schedule, timeZone, count?) |
prefs | get(), update(partial) |
appSettings | get(), update(partial) |
users | list(), create({email, name, password, role?}), setRole(id, role), delete(id) |
apiKeys | list(), create({name, role, expiresInDays?}), revoke(id) |
identityProviders | list/create/update/delete/test; loginProviders() lists the public ones for a sign-in page |
databases, storage | engines(), list/create/update/delete/test/check; storage.list(), storage.set(kind, connectionId) |
eventStreams, alarmPipelines, reports, modes | list/create/update/delete, plus test and deliveries(limit?) where the route has them, modes.activate(id) |
audit, logs, diagnostics, clients | audit.tagWrites(query?), logs.list(query?), diagnostics.get(), clients.list(), clients.subscriptions(id) |
scriptConsole, restore, admin, status | scriptConsole.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
subscribecounts subscribers per path: ten components on one tag cost onetag.subscribeon the gateway, and a new subscriber is handed the last known value at once. The returned function unsubscribes; the last one sendstag.unsubscribe.writeis optimistic: subscribers immediately see the written value withpending: 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, orWrite timed outafter 5 seconds; the optimistic value is reverted after 2 seconds without a confirming value.replayis the history read (tag.replay); identical concurrent requests share one promise, and it rejects after 30 seconds.subscribeAlarmssendsalarm.subscribeand deliversalarm.snapshotthen everyalarm.*frame.applyAlarmFrame(alarms, frame)folds a frame into a list, most severe first.screenEventcorrelates byrequestIdand 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 callsonTagsChanged(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:
- The opening state (
replay.valueframes withinitial: true, the snapshot of alarms) is buffered until the gateway'sreplay.statussaysopened. Subscribers keep their live values until then. - Then every subscriber is handed its tag's replayed value with
replayed: true, and alarm listeners a replayedalarm.snapshot. A tag with no recorded value at that moment is shown asquality: "Bad"withqualityDetail: "No value was recorded for this tag at this time.". - Live
tag.valueandalarm.*frames are held back from listeners (the last live values are still kept).@gateway.*tags other than@gateway.time, and values the gateway markedprivate, keep showing live. writeandscreenEventreject withReplayModeError(REPLAY_ON_MESSAGE: "Replay is on. Return to live to change values."), as do alarm acknowledgements and shelving; each refusal is reported toonReplayBlockedas{action, path?, message}.@gateway.browser.*and private paths stay writable.stopReplayhands every subscriber its live value again and re-sendsalarm.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:
| Field | Type | Meaning |
|---|---|---|
path | string | null | The path subscribed |
value | T | undefined | The live value; undefined until the gateway has sent one |
quality | string | undefined | As the gateway says it |
good | boolean | true only for quality Good |
qualityDetail | string | undefined | Why it is not good |
timestamp | number | undefined | Unix milliseconds |
formattedValue, unit | string | undefined | From the frame, when set |
loading | boolean | No value yet |
pending | boolean | A write from this client is not confirmed yet |
replayed | boolean | The value comes from a replay |
write | (value: T) => Promise<void> | Writes the tag |
message | TagValueMessage | undefined | The 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:
| Function | What 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:
| Type | Meaning |
|---|---|
Tag | A 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, UdtAlarmConfig | Tag 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, WsState | The WebSocket side |
ScreenEventInput, ScreenEventResult, ScreenDirectives, Screen, StartScreen, StartScreenInput, StartScreenList, StartScreenRole | Screens |
GatewayModule, ModuleState, ModuleLogEntry, DriverConnection, DriverConnectionInput, DriverBrowseResult, DriverTagProposal, OpcuaServer (exported as Server), MqttServer | Modules and drivers |
User, UserPrefs, AppSettings, ApiKey, IdentityProvider, GatewayClient, DatabaseConnection, StorageRoute, Report, EventStream, AlarmPipeline, GatewayMode, CronJob, CronRun, HistorianStatus | Administration |
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.