Skip to main content
Version: Next

A standalone React app on the SDK

This guide builds a small React app outside the dashboard that signs in with gateway accounts and shows live tags. It is modelled on the NIRO operator page, a separate app that runs at its own host on the SDK. It is for the developer who wants a custom front end without touching the gateway.

Prerequisites​

  • Node.js and a package manager (pnpm is used below; npm works the same way).
  • A checkout of the platform repository to pack the SDK from: platform/packages/sdk. The SDK is not on a public registry at version 0.1, so it is vendored as a tarball.
  • A gateway you can reach, and an account on it.
  • For production: a host name for the app and a reverse proxy in front of both the app and the gateway (the gateway's own setup runs Traefik).

Steps​

1. Vendor the SDK​

cd platform/packages/sdk
pnpm install && pnpm build && pnpm pack # writes scadex.io-sdk-0.0.1.tgz
mkdir -p ../../../my-app/vendor && mv scadex.io-sdk-0.0.1.tgz ../../../my-app/vendor/

Pack it again after an SDK change. The package's React peer range is ^18.0.0 (optional), so install React 18:

{
"name": "my-app",
"private": true,
"type": "module",
"scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", "preview": "vite preview" },
"dependencies": {
"@scadex.io/sdk": "file:vendor/scadex.io-sdk-0.0.1.tgz",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.29",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.7.0",
"typescript": "^5.9.3",
"vite": "^5.4.21"
}
}

2. Make the app talk to its own origin​

The SDK speaks to the gateway at a base URL. The simplest arrangement is the one NIRO uses: the page and the gateway share an origin, and whatever serves the page forwards /api/* and /sync to the gateway. Then the session cookie and the WebSocket need nothing extra. In development Vite's proxy does the forwarding:

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

const gateway = process.env.GATEWAY ?? "https://app.scadex.io";

export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": { target: gateway, changeOrigin: true, secure: true },
"/sync": { target: gateway.replace(/^http/, "ws"), ws: true, changeOrigin: true, secure: true },
},
},
});

One module holds the two clients:

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

const origin = window.location.origin;
export const auth = createAuthClient(origin); // sign-in, session, sign-out
export const scadex = new ScadexClient(origin); // REST and the /sync socket

3. Sign in with gateway accounts​

useAuth from @scadex.io/sdk/react takes an object with getSession and returns user, isLoading and isAuthenticated. It reads the session once per source object, so hand it a new one after signing in or out:

// src/App.tsx
import { useMemo, useState } from "react";
import { ScadexProvider, useAuth } from "@scadex.io/sdk/react";
import { auth, scadex } from "./gateway";
import { Login } from "./Login";
import { Panel } from "./Panel";

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

if (isLoading) return <p role="status">Loading</p>;
if (!user) return <Login onSignedIn={again} />;
return (
<ScadexProvider client={scadex}>
<Panel onSignOut={async () => { await auth.signOut(); again(); }} />
</ScadexProvider>
);
}
// src/Login.tsx
import { useState, type FormEvent } from "react";
import { auth } from "./gateway";

export function Login({ onSignedIn }: { onSignedIn: () => void }) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);

const submit = async (event: FormEvent) => {
event.preventDefault();
const result = await auth.signIn.email({ email: email.trim(), password });
if (result.error) { setError("That email and password do not match."); return; }
onSignedIn();
};

return (
<form onSubmit={submit}>
<input type="email" autoComplete="username" value={email} onChange={e => setEmail(e.target.value)} required />
<input type="password" autoComplete="current-password" value={password} onChange={e => setPassword(e.target.value)} required />
{error && <p role="alert">{error}</p>}
<button type="submit">Sign in</button>
</form>
);
}

The accounts, roles and sign-in flow are the gateway's; see Auth API and Security. ScadexProvider takes the client and opens the /sync socket for the hooks beneath it.

4. Read a tag and write it​

useTag(path) returns the tag's live value and a write function. Writes go over /sync; the gateway checks the role (operator and up) and audits each one.

// src/Panel.tsx
import { useTag } from "@scadex.io/sdk/react";

export function Panel({ onSignOut }: { onSignOut: () => void }) {
const temperature = useTag<number>("Line1.Temperature");
const setpoint = useTag<number>("Line1.Setpoint");

return (
<main>
<p>Temperature: {temperature.value ?? "—"}</p>
<label>
Setpoint
<input type="number" defaultValue={setpoint.value ?? ""}
onBlur={e => void setpoint.write(Number(e.target.value))} />
</label>
<button onClick={onSignOut}>Sign out</button>
</main>
);
}

Other hooks from @scadex.io/sdk/react: useTags and useTagValues for several tags, useAlarms for the live alarm list, useReplay for replay, and useClient for the ScadexClient itself (client.write(path, value) and the REST surface). The whole surface is in TypeScript SDK.

Run it: pnpm dev, or GATEWAY=http://127.0.0.1:18090 pnpm dev against a gateway of your own. Signing in through the dev server needs the gateway to trust http://localhost:5173 as an origin (next step).

5. Serve it on its own host​

Build with pnpm build and serve dist/ with nginx; hashed assets are immutable, the page itself is always re-checked:

server {
listen 80;
root /usr/share/nginx/html;

location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}

location / {
add_header Cache-Control "no-cache";
try_files $uri /index.html;
}
}

The reverse proxy in front sends /api/* and /sync on the app's host to the gateway and everything else to nginx. With Traefik, as NIRO does it, that is a second router on the same host with a higher priority:

services:
my-app:
build: .
networks: [proxy]
labels:
- traefik.enable=true
- traefik.http.routers.my-app.rule=Host(`hmi.example.com`)
- traefik.http.routers.my-app.priority=50
- traefik.http.routers.my-app.entrypoints=ssl
- traefik.http.routers.my-app.tls=true
- traefik.http.services.my-app.loadbalancer.server.port=80
# The gateway's API and live socket, ahead of the page's catch-all.
- traefik.http.routers.my-app-api.rule=Host(`hmi.example.com`) && (PathPrefix(`/api/`) || Path(`/sync`))
- traefik.http.routers.my-app-api.priority=100
- traefik.http.routers.my-app-api.entrypoints=ssl
- traefik.http.routers.my-app-api.service=scadex-io@docker
- traefik.http.routers.my-app-api.tls=true
networks:
proxy:
external: true

scadex-io@docker is the gateway's Traefik service in its own compose file; name yours as your setup declares it.

6. Trust the new origin​

The gateway accepts sign-in and the /sync upgrade only from origins listed in AUTH_TRUSTED_ORIGINS, because browsers send cookies on cross-site WebSocket upgrades. Add the app's origin there (and http://localhost:5173 for development), then restart the gateway. See Configuration.

The session cookie is scoped to the host the browser talked to. That is why the app forwards /api and /sync on its own host rather than calling the gateway's host directly: a cookie set by hmi.example.com is sent back to hmi.example.com, and the proxy carries it to the gateway.

Where to go next​