@norskvideo/ctl-dev-kit 0.2.106 → 0.2.108

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/conventions/checks.sdk-app.yml +9 -2
  2. package/create-demo/create-demo.ts +191 -0
  3. package/create-demo/template/core/CLAUDE.md.tmpl +158 -0
  4. package/create-demo/template/core/README.md.tmpl +74 -0
  5. package/create-demo/template/core/ctl/install.sh +27 -0
  6. package/create-demo/template/core/ctl/lib.sh +146 -0
  7. package/create-demo/template/core/ctl/mode.sh +19 -0
  8. package/create-demo/template/core/ctl/push.sh +16 -0
  9. package/create-demo/template/core/ctl/site.example.env +30 -0
  10. package/create-demo/template/core/ctl/studio.example.env +12 -0
  11. package/create-demo/template/core/gitignore +18 -0
  12. package/create-demo/template/core/scripts/build.sh +30 -0
  13. package/create-demo/template/core/scripts/test-feed.sh +44 -0
  14. package/create-demo/template/dashboard/dashboard/index.html +13 -0
  15. package/create-demo/template/dashboard/dashboard/package.json +22 -0
  16. package/create-demo/template/dashboard/dashboard/src/App.tsx +61 -0
  17. package/create-demo/template/dashboard/dashboard/src/api/types.ts +38 -0
  18. package/create-demo/template/dashboard/dashboard/src/components/ImagePreview.tsx +23 -0
  19. package/create-demo/template/dashboard/dashboard/src/components/StatusPill.tsx +65 -0
  20. package/create-demo/template/dashboard/dashboard/src/hooks/useAlerts.ts +98 -0
  21. package/create-demo/template/dashboard/dashboard/src/hooks/useDiscovery.ts +88 -0
  22. package/create-demo/template/dashboard/dashboard/src/hooks/useEnv.ts +53 -0
  23. package/create-demo/template/dashboard/dashboard/src/hooks/useLiveComponent.ts +249 -0
  24. package/create-demo/template/dashboard/dashboard/src/lib/workflow.ts +11 -0
  25. package/create-demo/template/dashboard/dashboard/src/main.tsx +12 -0
  26. package/create-demo/template/dashboard/dashboard/src/styles.css +84 -0
  27. package/create-demo/template/dashboard/dashboard/src/vite-env.d.ts +1 -0
  28. package/create-demo/template/dashboard/dashboard/tsconfig.json +18 -0
  29. package/create-demo/template/dashboard/dashboard/vite.config.ts +35 -0
  30. package/create-product/cli.ts +39 -1
  31. package/create-product/features/components.ts +10 -1
  32. package/package.json +1 -1
@@ -0,0 +1,30 @@
1
+ # Where and how this demo runs under norsk-ctl. Copy to ctl/site.env (which
2
+ # is gitignored) and fill it in; ctl/install.sh, push.sh and mode.sh read it.
3
+
4
+ # The machine norsk-ctl runs on: user@host over ssh, or empty for this one.
5
+ DEMO_HOST=
6
+
7
+ # Where the working dir is staged on that machine. It must be a path the
8
+ # norsk-ctl user can reach: a directory under someone's home usually is not.
9
+ WORKDIR=/srv/__DEMO__
10
+
11
+ # The user the norsk-ctl daemon runs as: the working dir is owned by it and
12
+ # norsk-ctl is run as it (via sudo). Empty runs both as the ssh/local user.
13
+ RUN_AS=norsk
14
+
15
+ # The norsk-ctl command on that machine.
16
+ NORSK_CTL=norsk-ctl
17
+
18
+ # A path on that machine to the norsk-studio licence, used once to add the
19
+ # studio-dev product template if norsk-ctl does not have it yet.
20
+ LICENSE_FILE=
21
+
22
+ # docker (norsk-ctl's default) or hybrid. hybrid puts media on the host
23
+ # network: needed when a viewer receives WebRTC media (WHEP, WHIP) directly.
24
+ NETWORK_MODE=docker
25
+
26
+ # Ingest ports to publish on the host, space separated, HOST[:CONTAINER][/udp].
27
+ # Everything else is allocated by norsk-ctl; pin a port only when a sender or
28
+ # a firewall outside the box must agree on it. 5001/udp is the SRT input
29
+ # (srt_in in the workflow); change both together.
30
+ HOST_PORTS="5001/udp"
@@ -0,0 +1,12 @@
1
+ # Site settings for the workflow. Copy to ctl/studio.env (which is gitignored).
2
+ #
3
+ # Each line is a Studio environment override, <COMPONENT_ID>_<FIELD>=value:
4
+ # it replaces that field of that component in the workflow YAML at launch, for
5
+ # any field the component declares overridable. This is where ports,
6
+ # passphrases and destinations go, so the YAML carries no site's values and no
7
+ # secrets. ctl/push.sh writes these into the overrides norsk-ctl launches with.
8
+ #
9
+ # A port changed here must be changed in HOST_PORTS (ctl/site.env) too.
10
+
11
+ # SRT_IN_PORT=5001
12
+ # SRT_IN_PASSPHRASE=
@@ -0,0 +1,18 @@
1
+ # The site's settings: copied from the .example files, never committed.
2
+ ctl/site.env
3
+ ctl/studio.env
4
+ ctl/.mode
5
+
6
+ node_modules/
7
+
8
+ # Build output, rebuilt by scripts/build.sh.
9
+ dashboard/dist/
10
+ components/lib/
11
+ components/src/**/_gen/
12
+ workdir/studio-save-files/*/dashboards/
13
+ workdir/components/
14
+
15
+ # What Studio writes into its working dir while it runs.
16
+ workdir/logs/
17
+ workdir/.norsk/
18
+ workdir/moq-certs/
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env bash
2
+ # Build what this demo adds to Studio, into workdir/ where Studio finds it:
3
+ # dashboard/ -> workdir/studio-save-files/__DEMO__/dashboards/ served at /dashboard/__DEMO__/
4
+ # components/ -> workdir/components/@__DEMO__/library/ loaded as a Studio library
5
+ # A demo without either has nothing to build.
6
+ set -euo pipefail
7
+ root="$(cd "$(dirname "$0")/.." && pwd)"
8
+
9
+ install_to() {
10
+ local from="$1" dest="$2"
11
+ rm -rf "$dest"
12
+ mkdir -p "$dest"
13
+ cp -r "$from/." "$dest/"
14
+ echo "installed ${dest#"$root"/}"
15
+ }
16
+
17
+ if [[ -d "$root/dashboard" ]]; then
18
+ # vite 8 needs util.parseEnv, which bun 1.1 does not have.
19
+ if ! bun -e 'if (typeof require("node:util").parseEnv !== "function") process.exit(1)' 2>/dev/null; then
20
+ echo "error: bun $(bun --version) is too old to build the dashboard (vite 8 needs util.parseEnv): run bun upgrade" >&2
21
+ exit 1
22
+ fi
23
+ (cd "$root/dashboard" && bun install >/dev/null && bun run build)
24
+ install_to "$root/dashboard/dist" "$root/workdir/studio-save-files/__DEMO__/dashboards"
25
+ fi
26
+
27
+ if [[ -d "$root/components" ]]; then
28
+ (cd "$root/components" && bun install >/dev/null && bun run build)
29
+ install_to "$root/components/lib/library" "$root/workdir/components/@__DEMO__/library"
30
+ fi
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env bash
2
+ # A labelled test picture with a tone, sent into the SRT input (srt_in), so the
3
+ # demo has something to show without a camera. Runs ffmpeg in docker.
4
+ #
5
+ # scripts/test-feed.sh up|down
6
+ #
7
+ # FEED_HOST is where the demo runs (default this machine); the port is
8
+ # SRT_IN_PORT from ctl/studio.env, else the workflow's 5001.
9
+ set -euo pipefail
10
+ root="$(cd "$(dirname "$0")/.." && pwd)"
11
+
12
+ # A setting from ctl/studio.env, or nothing: an unset one is not an error.
13
+ studio_env() { grep -E "^$1=" "$root/ctl/studio.env" 2>/dev/null | cut -d= -f2- || true; }
14
+
15
+ host="${FEED_HOST:-127.0.0.1}"
16
+ port="$(studio_env SRT_IN_PORT)"
17
+ port="${port:-5001}"
18
+ passphrase="$(studio_env SRT_IN_PASSPHRASE)"
19
+ image="${FFMPEG_IMAGE:-linuxserver/ffmpeg:8.0.1}"
20
+ name="__DEMO__-test-feed"
21
+
22
+ case "${1:-up}" in
23
+ up)
24
+ url="srt://${host}:${port}?mode=caller&latency=200000"
25
+ if [[ -n "$passphrase" ]]; then url="${url}&passphrase=${passphrase}"; fi
26
+ docker rm -f "$name" >/dev/null 2>&1 || true
27
+ docker run -d --restart unless-stopped --name "$name" --network host "$image" \
28
+ -hide_banner -loglevel warning -re \
29
+ -f lavfi -i "testsrc2=size=1280x720:rate=25" \
30
+ -f lavfi -i "sine=frequency=440:sample_rate=48000" \
31
+ -vf "drawtext=text='__DEMO__ %{localtime\:%T}':fontcolor=white:fontsize=64:x=40:y=40" \
32
+ -c:v libx264 -preset veryfast -tune zerolatency -g 50 -b:v 3M -pix_fmt yuv420p \
33
+ -c:a aac -b:a 128k -ac 2 \
34
+ -f mpegts "$url" >/dev/null
35
+ echo "test feed -> ${host}:${port}"
36
+ ;;
37
+ down)
38
+ docker rm -f "$name" >/dev/null 2>&1 && echo "test feed stopped" || true
39
+ ;;
40
+ *)
41
+ echo "usage: $0 up|down" >&2
42
+ exit 2
43
+ ;;
44
+ esac
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <link rel="icon" href="data:," />
5
+ <meta charset="utf-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <title>__DEMO__</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "__DEMO__-dashboard",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc --noEmit && vite build",
9
+ "typecheck": "tsc --noEmit"
10
+ },
11
+ "dependencies": {
12
+ "react": "^19.0.0",
13
+ "react-dom": "^19.0.0"
14
+ },
15
+ "devDependencies": {
16
+ "@types/react": "^19.0.10",
17
+ "@types/react-dom": "^19.0.3",
18
+ "@vitejs/plugin-react": "^6.0.1",
19
+ "typescript": "^5.7.2",
20
+ "vite": "^8.0.0"
21
+ }
22
+ }
@@ -0,0 +1,61 @@
1
+ import type { EnvResponse, WorkflowComponentDescriptor } from "./api/types";
2
+ import { ImagePreview } from "./components/ImagePreview";
3
+ import { StatusPill } from "./components/StatusPill";
4
+ import { labelFromAlerts, toneFromAlerts, useAlerts } from "./hooks/useAlerts";
5
+ import { useDiscovery } from "./hooks/useDiscovery";
6
+ import { useEnv } from "./hooks/useEnv";
7
+ import { useLiveComponent } from "./hooks/useLiveComponent";
8
+ import { WORKFLOW } from "./lib/workflow";
9
+
10
+ // The starter page: the program preview, and every running component with its
11
+ // health and live state. Replace it with what the operator actually needs; the
12
+ // hooks are the parts to keep.
13
+ export function App() {
14
+ const { env, error } = useEnv();
15
+ const { components, loading } = useDiscovery(env);
16
+
17
+ if (error) return <main className="page">Cannot reach Studio: {error}</main>;
18
+ return (
19
+ <main className="page">
20
+ <header>
21
+ <h1>__DEMO__</h1>
22
+ </header>
23
+ <ImagePreview componentId={WORKFLOW.preview.id} env={env} />
24
+ <section>
25
+ <h2>Components</h2>
26
+ {loading ? (
27
+ <p>Waiting for the workflow to start</p>
28
+ ) : (
29
+ <ul className="components">
30
+ {components.map((c) => (
31
+ <ComponentRow key={c.componentId} component={c} env={env} />
32
+ ))}
33
+ </ul>
34
+ )}
35
+ </section>
36
+ </main>
37
+ );
38
+ }
39
+
40
+ function ComponentRow({ component, env }: { component: WorkflowComponentDescriptor; env: EnvResponse | null }) {
41
+ const { alerts, loaded } = useAlerts(component.componentId, env);
42
+ return (
43
+ <li>
44
+ <span className="component-name">{component.displayName ?? component.componentId}</span>
45
+ <span className="component-type">{component.componentType}</span>
46
+ <StatusPill tone={toneFromAlerts(alerts, loaded)} label={labelFromAlerts(alerts, loaded, { live: "OK" })} />
47
+ {component.capabilities.hasState && <LiveState componentId={component.componentId} env={env} />}
48
+ </li>
49
+ );
50
+ }
51
+
52
+ function LiveState({ componentId, env }: { componentId: string; env: EnvResponse | null }) {
53
+ const { state } = useLiveComponent<unknown>({ componentId, env });
54
+ if (state === null) return null;
55
+ return (
56
+ <details className="component-state">
57
+ <summary>State</summary>
58
+ <pre>{JSON.stringify(state, null, 2)}</pre>
59
+ </details>
60
+ );
61
+ }
@@ -0,0 +1,38 @@
1
+ // What the dashboard reads from Studio. The discovery response is the
2
+ // workflow's `<apiBasePath>/components` endpoint, which returns each running
3
+ // component's type and capabilities only: its config and state arrive over the
4
+ // component's websocket (see useLiveComponent).
5
+
6
+ export interface EnvResponse {
7
+ studioUrlPrefix: string;
8
+ apiBasePath: string;
9
+ wsBasePath: string;
10
+ note?: string;
11
+ }
12
+
13
+ export interface ComponentCapabilities {
14
+ hasHttp: boolean;
15
+ hasWebSocket: boolean;
16
+ hasViews: boolean;
17
+ hasState: boolean;
18
+ }
19
+
20
+ export interface WorkflowComponentDescriptor {
21
+ componentId: string;
22
+ componentType: string;
23
+ displayName?: string;
24
+ capabilities: ComponentCapabilities;
25
+ }
26
+
27
+ export interface ComponentsResponse {
28
+ components: WorkflowComponentDescriptor[];
29
+ totalComponents: number;
30
+ }
31
+
32
+ // `output.preview`'s state. `url` depends on the preview mode: a JPEG URL in
33
+ // image mode, a WHEP endpoint in webrtc mode, a WebTransport base URL in moq.
34
+ export interface PreviewState {
35
+ url?: string;
36
+ previewMode?: string;
37
+ [k: string]: unknown;
38
+ }
@@ -0,0 +1,23 @@
1
+ import { useCallback, useState } from "react";
2
+ import type { EnvResponse, PreviewState } from "../api/types";
3
+ import { useLiveComponent } from "../hooks/useLiveComponent";
4
+
5
+ // The program as a still from an `output.preview` in image mode. Its state
6
+ // carries the first JPEG's URL, and each newer one arrives as a `url-published`
7
+ // event, about once a second. No player and no media ports, so it works in
8
+ // norsk-ctl's docker network mode. For live video, see the CLAUDE.md
9
+ // "Dashboard" notes.
10
+ export function ImagePreview({ componentId, env }: { componentId: string; env: EnvResponse | null }) {
11
+ const [published, setPublished] = useState<string | null>(null);
12
+ const onEvent = useCallback((evt: unknown) => {
13
+ const e = evt as { type?: string; url?: unknown };
14
+ if (e.type === "url-published" && typeof e.url === "string") setPublished(e.url);
15
+ }, []);
16
+ const { state, connected } = useLiveComponent<PreviewState>({ componentId, env, onEvent });
17
+
18
+ const url = published ?? (typeof state?.url === "string" && state.url.length > 0 ? state.url : null);
19
+ if (!url) {
20
+ return <div className="preview preview-empty">{connected ? "Waiting for a picture" : "Connecting"}</div>;
21
+ }
22
+ return <img className="preview" src={url} alt="Program preview" />;
23
+ }
@@ -0,0 +1,65 @@
1
+ // A status pill. Six tones aligned with what Studio's per-component /alerts
2
+ // endpoint reports.
3
+ // A component's tone is derived from its live alerts list:
4
+ //
5
+ // live — no alerts (Studio considers this component healthy)
6
+ // ready — warning-level alerts only (e.g. "no active connections")
7
+ // waiting — expected upstream not yet reached (config-derived)
8
+ // idle — configured but off / disabled (config-derived)
9
+ // error — any error-level alert (e.g. "receiving no data")
10
+ // unknown — Studio's state doesn't expose the signal we'd need
11
+ //
12
+ // Card headers may add a matching border-left accent via `toneAccentColor`.
13
+
14
+ export type StatusTone = "live" | "ready" | "waiting" | "idle" | "error" | "unknown";
15
+
16
+ interface StatusPillProps {
17
+ tone: StatusTone;
18
+ label: string;
19
+ }
20
+
21
+ const PALETTE: Record<StatusTone, { background: string; color: string; border: string }> = {
22
+ live: {
23
+ background: "rgba(34, 197, 94, 0.20)",
24
+ color: "rgb(134, 239, 172)",
25
+ border: "1px solid rgba(34, 197, 94, 0.55)",
26
+ },
27
+ ready: {
28
+ background: "rgba(59, 130, 246, 0.20)",
29
+ color: "rgb(147, 197, 253)",
30
+ border: "1px solid rgba(59, 130, 246, 0.55)",
31
+ },
32
+ waiting: {
33
+ background: "rgba(234, 179, 8, 0.20)",
34
+ color: "rgb(253, 224, 71)",
35
+ border: "1px solid rgba(234, 179, 8, 0.55)",
36
+ },
37
+ idle: {
38
+ background: "rgba(107, 114, 128, 0.20)",
39
+ color: "rgb(209, 213, 219)",
40
+ border: "1px solid rgba(107, 114, 128, 0.55)",
41
+ },
42
+ error: {
43
+ background: "rgba(239, 68, 68, 0.20)",
44
+ color: "rgb(252, 165, 165)",
45
+ border: "1px solid rgba(239, 68, 68, 0.55)",
46
+ },
47
+ unknown: {
48
+ background: "rgba(107, 114, 128, 0.08)",
49
+ color: "rgb(156, 163, 175)",
50
+ border: "1px dashed rgba(107, 114, 128, 0.55)",
51
+ },
52
+ };
53
+
54
+ export function StatusPill({ tone, label }: StatusPillProps) {
55
+ return (
56
+ <span className="status-pill" style={PALETTE[tone]} data-tone={tone}>
57
+ {label}
58
+ </span>
59
+ );
60
+ }
61
+
62
+ // For card border-left accents matching a section's aggregate status.
63
+ export function toneAccentColor(tone: StatusTone): string {
64
+ return PALETTE[tone].border.replace("1px solid ", "").replace("1px dashed ", "");
65
+ }
@@ -0,0 +1,98 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { EnvResponse } from "../api/types";
3
+ import type { StatusTone } from "../components/StatusPill";
4
+
5
+ // Studio's per-component alert. Emitted by `setAlert(id, {level, message})`
6
+ // in the runtime; polled from `<apiBasePath>/<componentId>/alerts`. Cleared
7
+ // entries drop out of the endpoint response, so an empty array means the
8
+ // component is currently healthy (from Studio's own POV).
9
+ export interface Alert {
10
+ level: "error" | "warning" | "info";
11
+ message: string;
12
+ startMs?: number;
13
+ }
14
+
15
+ const POLL_INTERVAL_MS = 3_000;
16
+
17
+ // Polls Studio's alerts endpoint for a single component. Returns the current
18
+ // alert array plus a `loaded` flag distinguishing "no alerts, healthy" from
19
+ // "haven't fetched yet". Reset on componentId / env change.
20
+ export function useAlerts(componentId: string | null, env: EnvResponse | null): { alerts: Alert[]; loaded: boolean } {
21
+ const [alerts, setAlerts] = useState<Alert[]>([]);
22
+ const [loaded, setLoaded] = useState(false);
23
+
24
+ useEffect(() => {
25
+ if (!componentId || !env) return;
26
+ setLoaded(false);
27
+ setAlerts([]);
28
+ let cancelled = false;
29
+ const url = `${env.apiBasePath}/${componentId}/alerts`;
30
+ const poll = async () => {
31
+ try {
32
+ const res = await fetch(url, { credentials: "include" });
33
+ if (!res.ok) return;
34
+ const json = (await res.json()) as Alert[];
35
+ if (cancelled) return;
36
+ setAlerts(Array.isArray(json) ? json : []);
37
+ setLoaded(true);
38
+ } catch {
39
+ // Silent — proxy hiccups, transient errors. Stale alerts stay
40
+ // visible until the next poll succeeds.
41
+ }
42
+ };
43
+ void poll();
44
+ const timer = setInterval(poll, POLL_INTERVAL_MS);
45
+ return () => {
46
+ cancelled = true;
47
+ clearInterval(timer);
48
+ };
49
+ }, [componentId, env]);
50
+
51
+ return { alerts, loaded };
52
+ }
53
+
54
+ // Derive a StatusPill tone from an alert list. Errors dominate warnings;
55
+ // warnings dominate the healthy case. When the endpoint hasn't answered
56
+ // yet, callers should fall back to the "unknown" tone via the second arg.
57
+ export function toneFromAlerts(alerts: Alert[], loaded: boolean, fallbackTone: StatusTone = "unknown"): StatusTone {
58
+ if (!loaded) return fallbackTone;
59
+ if (alerts.some((a) => a.level === "error")) return "error";
60
+ if (alerts.some((a) => a.level === "warning")) return "ready";
61
+ return "live";
62
+ }
63
+
64
+ // Rewrite Studio's internal alert messages into copy that doesn't require
65
+ // knowing what a "component" is. Studio phrases everything in terms of the
66
+ // producing/consuming component; we recast for the operator reading the
67
+ // dashboard. Optional `context` lets callers narrow to a role-specific
68
+ // noun ("source", "camera", etc.).
69
+ export function humanizeAlert(
70
+ message: string,
71
+ context?: { subject?: string; kind?: "input" | "output" | "session" },
72
+ ): string {
73
+ const subject = context?.subject ?? "This source";
74
+ if (/receiving no data/i.test(message)) {
75
+ return context?.kind === "input" ? "No data arriving from upstream" : `${subject}: nothing arriving yet`;
76
+ }
77
+ if (/sending no data/i.test(message)) {
78
+ return context?.kind === "output" ? "Not producing output yet" : `${subject}: not producing output yet`;
79
+ }
80
+ if (/no active connections/i.test(message)) return "No active connection";
81
+ return message.replace(/^This component /i, "");
82
+ }
83
+
84
+ // Short label for the pill based on alert state. `livedLabel` and
85
+ // `errorLabel` let callers customise per row-type (e.g. "3 streams" vs
86
+ // "Publishing").
87
+ export function labelFromAlerts(
88
+ alerts: Alert[],
89
+ loaded: boolean,
90
+ labels: { live: string; warning?: string; error?: string; unknown?: string },
91
+ ): string {
92
+ if (!loaded) return labels.unknown ?? "Connecting";
93
+ const errorCount = alerts.filter((a) => a.level === "error").length;
94
+ if (errorCount > 0) return labels.error ?? `${errorCount} error${errorCount === 1 ? "" : "s"}`;
95
+ const warningCount = alerts.filter((a) => a.level === "warning").length;
96
+ if (warningCount > 0) return labels.warning ?? "Warning";
97
+ return labels.live;
98
+ }
@@ -0,0 +1,88 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { ComponentsResponse, EnvResponse, WorkflowComponentDescriptor } from "../api/types";
3
+
4
+ interface UseDiscoveryResult {
5
+ components: WorkflowComponentDescriptor[];
6
+ error: string | null;
7
+ loading: boolean;
8
+ attempts: number;
9
+ nextRetryMs: number | null;
10
+ }
11
+
12
+ // Bounded-exponential-backoff poller of `<apiBasePath>/components`. Studio
13
+ // returns 503 with `{error: "Workflow is not running yet"}` until the
14
+ // workflow boots; we keep polling until the response is 200 with a non-empty
15
+ // `components` array. Once we have it, we exit the loop and the component
16
+ // stays mounted regardless of later disconnects — the per-component WS hooks own
17
+ // their own reconnect.
18
+ const INITIAL_DELAY_MS = 500;
19
+ const MAX_DELAY_MS = 5000;
20
+
21
+ export function useDiscovery(env: EnvResponse | null): UseDiscoveryResult {
22
+ const [components, setComponents] = useState<WorkflowComponentDescriptor[]>([]);
23
+ const [error, setError] = useState<string | null>(null);
24
+ const [loading, setLoading] = useState(true);
25
+ const [attempts, setAttempts] = useState(0);
26
+ const [nextRetryMs, setNextRetryMs] = useState<number | null>(null);
27
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
28
+
29
+ useEffect(() => {
30
+ if (!env) return;
31
+ let cancelled = false;
32
+ let delay = INITIAL_DELAY_MS;
33
+ let attempt = 0;
34
+
35
+ const tick = async () => {
36
+ attempt += 1;
37
+ if (cancelled) return;
38
+ setAttempts(attempt);
39
+ try {
40
+ const res = await fetch(`${env.apiBasePath}/components`);
41
+ if (res.status === 503) {
42
+ // Workflow not yet running — bounded retry.
43
+ scheduleRetry();
44
+ return;
45
+ }
46
+ if (!res.ok) {
47
+ throw new Error(`HTTP ${res.status}`);
48
+ }
49
+ const body = (await res.json()) as ComponentsResponse;
50
+ if (!body.components || body.components.length === 0) {
51
+ // Empty surface — keep polling.
52
+ scheduleRetry();
53
+ return;
54
+ }
55
+ if (cancelled) return;
56
+ setComponents(body.components);
57
+ setError(null);
58
+ setLoading(false);
59
+ setNextRetryMs(null);
60
+ } catch (err) {
61
+ if (cancelled) return;
62
+ setError(err instanceof Error ? err.message : String(err));
63
+ scheduleRetry();
64
+ }
65
+ };
66
+
67
+ const scheduleRetry = () => {
68
+ if (cancelled) return;
69
+ setNextRetryMs(delay);
70
+ timerRef.current = setTimeout(() => {
71
+ delay = Math.min(delay * 2, MAX_DELAY_MS);
72
+ void tick();
73
+ }, delay);
74
+ };
75
+
76
+ void tick();
77
+
78
+ return () => {
79
+ cancelled = true;
80
+ if (timerRef.current) {
81
+ clearTimeout(timerRef.current);
82
+ timerRef.current = null;
83
+ }
84
+ };
85
+ }, [env]);
86
+
87
+ return { components, error, loading, attempts, nextRetryMs };
88
+ }
@@ -0,0 +1,53 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { EnvResponse } from "../api/types";
3
+
4
+ interface UseEnvResult {
5
+ env: EnvResponse | null;
6
+ error: string | null;
7
+ loading: boolean;
8
+ }
9
+
10
+ // Resolves the dashboard's bound `env` endpoint. Studio serves
11
+ // `/dashboard/<workflowName>/env` and returns `{studioUrlPrefix, apiBasePath, wsBasePath}`.
12
+ // We fetch via a relative URL so the bundle works under arbitrary
13
+ // `studioUrlPrefix` deployments.
14
+ export function useEnv(): UseEnvResult {
15
+ const [env, setEnv] = useState<EnvResponse | null>(null);
16
+ const [error, setError] = useState<string | null>(null);
17
+ const [loading, setLoading] = useState(true);
18
+
19
+ useEffect(() => {
20
+ let cancelled = false;
21
+
22
+ (async () => {
23
+ try {
24
+ const res = await fetch("./env");
25
+ if (!res.ok) {
26
+ throw new Error(`env returned HTTP ${res.status}`);
27
+ }
28
+ const body = (await res.json()) as Partial<EnvResponse>;
29
+ if (!body.wsBasePath || !body.apiBasePath) {
30
+ throw new Error("env response missing wsBasePath/apiBasePath");
31
+ }
32
+ if (cancelled) return;
33
+ setEnv({
34
+ studioUrlPrefix: body.studioUrlPrefix ?? "",
35
+ apiBasePath: body.apiBasePath,
36
+ wsBasePath: body.wsBasePath,
37
+ note: body.note,
38
+ });
39
+ setLoading(false);
40
+ } catch (err) {
41
+ if (cancelled) return;
42
+ setError(err instanceof Error ? err.message : String(err));
43
+ setLoading(false);
44
+ }
45
+ })();
46
+
47
+ return () => {
48
+ cancelled = true;
49
+ };
50
+ }, []);
51
+
52
+ return { env, error, loading };
53
+ }