@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,249 @@
1
+ import type * as React from "react";
2
+ import { useEffect, useReducer, useRef } from "react";
3
+ import type { EnvResponse } from "../api/types";
4
+
5
+ // Inbound WS frame shapes — server → client. Studio's public WS
6
+ // (`<wsBasePath>/<id>/ws`) emits exactly these.
7
+ type IncomingMessage =
8
+ | { type: "initial-state"; state: unknown }
9
+ | { type: "state-update"; state: unknown }
10
+ | { type: "event"; event: unknown }
11
+ | { type: "alert"; alert: unknown }
12
+ | { type: "error"; message: string };
13
+
14
+ interface LiveComponentState<S, C = unknown> {
15
+ state: S | null;
16
+ config: C | null;
17
+ connected: boolean;
18
+ hasInitialState: boolean;
19
+ lastError: string | null;
20
+ disconnected: boolean;
21
+ }
22
+
23
+ type Action<S, C> =
24
+ | { type: "CONNECT" }
25
+ | { type: "DISCONNECT" }
26
+ | { type: "INIT"; state: S }
27
+ | { type: "UPDATE"; state: S }
28
+ | { type: "EVENT"; event: unknown }
29
+ | { type: "ALERT"; alert: unknown }
30
+ | { type: "ERROR"; message: string }
31
+ | { type: "CONFIG"; config: C };
32
+
33
+ function reducer<S, C>(state: LiveComponentState<S, C>, action: Action<S, C>): LiveComponentState<S, C> {
34
+ switch (action.type) {
35
+ case "CONNECT":
36
+ return { ...state, connected: true, disconnected: false, lastError: null };
37
+ case "DISCONNECT":
38
+ return { ...state, connected: false, disconnected: true };
39
+ case "INIT":
40
+ return { ...state, state: action.state, hasInitialState: true };
41
+ case "UPDATE":
42
+ return { ...state, state: action.state };
43
+ case "EVENT":
44
+ // Events go to the onEvent callback, not into this state.
45
+ return state;
46
+ case "ALERT":
47
+ return state;
48
+ case "ERROR":
49
+ return { ...state, lastError: action.message };
50
+ case "CONFIG":
51
+ return { ...state, config: action.config };
52
+ default:
53
+ return state;
54
+ }
55
+ }
56
+
57
+ export interface UseLiveComponentResult<S, C = unknown> {
58
+ state: S | null;
59
+ config: C | null;
60
+ connected: boolean;
61
+ hasInitialState: boolean;
62
+ lastError: string | null;
63
+ sendCommand: (cmd: unknown) => void;
64
+ }
65
+
66
+ interface UseLiveComponentOptions<C> {
67
+ componentId: string;
68
+ env: EnvResponse | null;
69
+ // Optional initial config to seed the reducer. The discovery payload does
70
+ // not carry config; callers may supply a known default (e.g. one known
71
+ // from the workflow YAML), or leave null and render a loading state until
72
+ // state arrives.
73
+ initialConfig?: C | null;
74
+ // Optional callback fired whenever an `event` WS frame arrives. Studio
75
+ // broadcasts many state transitions as events (see e.g. videoCompose's
76
+ // `layout-changed`, `resolved-layout-changed`) without necessarily
77
+ // following up with a `state-update` snapshot. Consumers that need those
78
+ // deltas to drive local rendering should implement `onEvent` to reduce
79
+ // events into their own state — the hook itself keeps `EVENT` as a
80
+ // no-op in the reducer so no policy about event shapes leaks in.
81
+ //
82
+ // The callback identity is captured in a ref, so passing a fresh function
83
+ // per render does NOT trigger a WS reconnect.
84
+ onEvent?: (evt: unknown) => void;
85
+ }
86
+
87
+ const RECONNECT_INITIAL_MS = 500;
88
+ const RECONNECT_MAX_MS = 10_000;
89
+
90
+ // Extracted for direct unit testing without a live WebSocket. Exported under
91
+ // a __-prefixed alias so consumers don't reach for it as public API.
92
+ export function __handleIncomingFrame<S>(
93
+ parsed: IncomingMessage,
94
+ dispatch: (action: Action<S, unknown>) => void,
95
+ onEventRef: { current: ((evt: unknown) => void) | null | undefined },
96
+ ): void {
97
+ switch (parsed.type) {
98
+ case "initial-state":
99
+ dispatch({ type: "INIT", state: parsed.state as S });
100
+ break;
101
+ case "state-update":
102
+ dispatch({ type: "UPDATE", state: parsed.state as S });
103
+ break;
104
+ case "event":
105
+ dispatch({ type: "EVENT", event: parsed.event });
106
+ onEventRef.current?.(parsed.event);
107
+ break;
108
+ case "alert":
109
+ dispatch({ type: "ALERT", alert: parsed.alert });
110
+ break;
111
+ case "error":
112
+ dispatch({ type: "ERROR", message: parsed.message });
113
+ break;
114
+ default:
115
+ break;
116
+ }
117
+ }
118
+
119
+ export function useLiveComponent<S, C = unknown>(opts: UseLiveComponentOptions<C>): UseLiveComponentResult<S, C> {
120
+ const { componentId, env, initialConfig = null, onEvent } = opts;
121
+
122
+ // Capture the callback in a ref so the WS `useEffect`'s dep array can stay
123
+ // `[componentId, env]` — otherwise every parent re-render with a fresh
124
+ // callback identity would tear down + reconnect the socket.
125
+ const onEventRef = useRef<UseLiveComponentOptions<C>["onEvent"]>(onEvent);
126
+ onEventRef.current = onEvent;
127
+
128
+ const [state, dispatch] = useReducer(reducer as React.Reducer<LiveComponentState<S, C>, Action<S, C>>, {
129
+ state: null,
130
+ config: initialConfig,
131
+ connected: false,
132
+ hasInitialState: false,
133
+ lastError: null,
134
+ disconnected: false,
135
+ });
136
+
137
+ const wsRef = useRef<WebSocket | null>(null);
138
+ const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
139
+ const reconnectDelayRef = useRef<number>(RECONNECT_INITIAL_MS);
140
+
141
+ // Refresh seeded config if caller changes the prop.
142
+ useEffect(() => {
143
+ if (initialConfig) {
144
+ dispatch({ type: "CONFIG", config: initialConfig });
145
+ }
146
+ }, [initialConfig]);
147
+
148
+ useEffect(() => {
149
+ if (!env) return;
150
+ let cancelled = false;
151
+
152
+ const connect = () => {
153
+ if (cancelled) return;
154
+
155
+ // Compose the absolute WS URL relative to window.location. `env.wsBasePath`
156
+ // is path-only (e.g. `/live`). Honour the page's protocol — `wss:` when
157
+ // served over HTTPS.
158
+ const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
159
+ const url = `${proto}//${window.location.host}${env.wsBasePath}/${componentId}/ws`;
160
+
161
+ let ws: WebSocket;
162
+ try {
163
+ ws = new WebSocket(url);
164
+ } catch (err) {
165
+ dispatch({ type: "ERROR", message: err instanceof Error ? err.message : String(err) });
166
+ scheduleReconnect();
167
+ return;
168
+ }
169
+ wsRef.current = ws;
170
+
171
+ ws.onopen = () => {
172
+ if (cancelled) return;
173
+ reconnectDelayRef.current = RECONNECT_INITIAL_MS;
174
+ dispatch({ type: "CONNECT" });
175
+ };
176
+
177
+ ws.onmessage = (event: MessageEvent) => {
178
+ if (cancelled) return;
179
+ let parsed: IncomingMessage;
180
+ try {
181
+ parsed = JSON.parse(typeof event.data === "string" ? event.data : "") as IncomingMessage;
182
+ } catch (err) {
183
+ dispatch({ type: "ERROR", message: `bad frame: ${err instanceof Error ? err.message : String(err)}` });
184
+ return;
185
+ }
186
+
187
+ __handleIncomingFrame<S>(parsed, dispatch as (a: Action<S, unknown>) => void, onEventRef);
188
+ };
189
+
190
+ ws.onerror = () => {
191
+ // The error event surface is opaque; rely on onclose to drive
192
+ // reconnect.
193
+ dispatch({ type: "ERROR", message: "websocket error" });
194
+ };
195
+
196
+ ws.onclose = () => {
197
+ if (cancelled) return;
198
+ wsRef.current = null;
199
+ dispatch({ type: "DISCONNECT" });
200
+ scheduleReconnect();
201
+ };
202
+ };
203
+
204
+ const scheduleReconnect = () => {
205
+ if (cancelled) return;
206
+ const delay = reconnectDelayRef.current;
207
+ reconnectDelayRef.current = Math.min(delay * 2, RECONNECT_MAX_MS);
208
+ reconnectTimerRef.current = setTimeout(() => {
209
+ connect();
210
+ }, delay);
211
+ };
212
+
213
+ connect();
214
+
215
+ return () => {
216
+ cancelled = true;
217
+ if (reconnectTimerRef.current) {
218
+ clearTimeout(reconnectTimerRef.current);
219
+ reconnectTimerRef.current = null;
220
+ }
221
+ if (wsRef.current) {
222
+ try {
223
+ wsRef.current.close();
224
+ } catch {
225
+ // ignore
226
+ }
227
+ wsRef.current = null;
228
+ }
229
+ };
230
+ }, [componentId, env]);
231
+
232
+ const sendCommand = (cmd: unknown) => {
233
+ const ws = wsRef.current;
234
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
235
+ console.warn(`useLiveComponent[${componentId}]: dropping command — socket not open`);
236
+ return;
237
+ }
238
+ ws.send(JSON.stringify({ type: "command", command: cmd }));
239
+ };
240
+
241
+ return {
242
+ state: state.state,
243
+ config: state.config,
244
+ connected: state.connected,
245
+ hasInitialState: state.hasInitialState,
246
+ lastError: state.lastError,
247
+ sendCommand,
248
+ };
249
+ }
@@ -0,0 +1,11 @@
1
+ // The one place the dashboard learns the workflow's shape. Component ids here
2
+ // must match workdir/studio-save-files/__DEMO__/__DEMO__.yaml; when the
3
+ // workflow changes, change it here too.
4
+
5
+ export const WORKFLOW = {
6
+ // input.srt-listener: the camera or encoder feeding the demo.
7
+ input: { id: "srt_in", label: "SRT in" },
8
+
9
+ // output.preview in image mode: a still of the program, refreshed.
10
+ preview: { id: "preview" },
11
+ } as const;
@@ -0,0 +1,12 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { App } from "./App";
4
+ import "./styles.css";
5
+
6
+ const root = document.getElementById("root");
7
+ if (!root) throw new Error("index.html has no #root");
8
+ createRoot(root).render(
9
+ <StrictMode>
10
+ <App />
11
+ </StrictMode>,
12
+ );
@@ -0,0 +1,84 @@
1
+ :root {
2
+ color-scheme: dark;
3
+ font-family: system-ui, sans-serif;
4
+ background: #0b0d10;
5
+ color: #e5e7eb;
6
+ }
7
+
8
+ body {
9
+ margin: 0;
10
+ }
11
+
12
+ .page {
13
+ max-width: 1100px;
14
+ margin: 0 auto;
15
+ padding: 24px 16px;
16
+ }
17
+
18
+ h1 {
19
+ font-size: 20px;
20
+ margin: 0 0 16px;
21
+ }
22
+
23
+ h2 {
24
+ font-size: 14px;
25
+ text-transform: uppercase;
26
+ letter-spacing: 0.05em;
27
+ color: #9ca3af;
28
+ }
29
+
30
+ .preview {
31
+ display: block;
32
+ width: 100%;
33
+ aspect-ratio: 16 / 9;
34
+ object-fit: contain;
35
+ background: #000;
36
+ border-radius: 6px;
37
+ }
38
+
39
+ .preview-empty {
40
+ display: grid;
41
+ place-items: center;
42
+ color: #9ca3af;
43
+ }
44
+
45
+ .components {
46
+ list-style: none;
47
+ padding: 0;
48
+ margin: 0;
49
+ }
50
+
51
+ .components li {
52
+ padding: 8px 0;
53
+ border-bottom: 1px solid #1f2937;
54
+ }
55
+
56
+ .component-name {
57
+ font-weight: 600;
58
+ }
59
+
60
+ .component-type {
61
+ margin-left: 8px;
62
+ color: #6b7280;
63
+ font-size: 12px;
64
+ }
65
+
66
+ .component-state {
67
+ margin: 6px 0 0;
68
+ font-size: 11px;
69
+ color: #9ca3af;
70
+ }
71
+
72
+ .component-state pre {
73
+ white-space: pre-wrap;
74
+ }
75
+
76
+ .status-pill {
77
+ display: inline-block;
78
+ margin-left: 8px;
79
+ padding: 2px 6px;
80
+ border-radius: 4px;
81
+ font-size: 10px;
82
+ text-transform: uppercase;
83
+ letter-spacing: 0.05em;
84
+ }
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "moduleResolution": "bundler",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "allowImportingTsExtensions": true,
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "react-jsx",
15
+ "noEmit": true
16
+ },
17
+ "include": ["src"]
18
+ }
@@ -0,0 +1,35 @@
1
+ import { resolve } from "node:path";
2
+ import react from "@vitejs/plugin-react";
3
+ import { defineConfig } from "vite";
4
+
5
+ // Studio serves the build from studio-save-files/__DEMO__/dashboards/ at
6
+ // /dashboard/__DEMO__/, so asset paths are relative. One page per HTML entry
7
+ // under build.rollupOptions.input; add a page by adding an entry.
8
+ //
9
+ // `bun run dev` proxies Studio's routes to STUDIO_URL, so the pages can be
10
+ // worked on against a running workflow. It must be a Studio reachable without
11
+ // the sign-in proxy in front.
12
+ const studio = process.env.STUDIO_URL ?? "http://localhost:8000";
13
+ const toStudio = { target: studio, changeOrigin: true, secure: false, ws: true };
14
+
15
+ export default defineConfig({
16
+ base: "./",
17
+ plugins: [react()],
18
+ resolve: { dedupe: ["react", "react-dom"] },
19
+ server: {
20
+ port: 5175,
21
+ proxy: {
22
+ "/env": { ...toStudio, rewrite: () => "/dashboard/__DEMO__/env" },
23
+ "/live": toStudio,
24
+ },
25
+ },
26
+ build: {
27
+ outDir: "dist",
28
+ emptyOutDir: true,
29
+ rollupOptions: {
30
+ input: {
31
+ index: resolve(__dirname, "index.html"),
32
+ },
33
+ },
34
+ },
35
+ });
@@ -5,6 +5,7 @@
5
5
  // so the bin runs under bun (the fleet's only runtime).
6
6
  import { existsSync, readdirSync } from "node:fs";
7
7
  import { parseArgs } from "node:util";
8
+ import { createDemo, type DemoFeature } from "../create-demo/create-demo.ts";
8
9
  import { createDashboard } from "../dashboard/create-dashboard.ts";
9
10
  import { writeDashboardManifest } from "../dashboard/manifest.ts";
10
11
  import { createProduct, type ProductFeature, type ProductShape, type StudioLibrary } from "./create-product.ts";
@@ -13,6 +14,8 @@ const SHAPES: ProductShape[] = ["backend-turnkey", "turnkey"];
13
14
 
14
15
  const USAGE = `Usage:
15
16
  ctl-dev-kit create-product --name <name> [options] <dir>
17
+ ctl-dev-kit create-demo --name <name> [--with dashboard,components] [--user <user>] <dir>
18
+ a customer demo: a Studio working dir installed into norsk-ctl
16
19
  ctl-dev-kit create-dashboard --name <name> <dir> a fleet dashboard norsk-mgr hosts (a live runtime list)
17
20
  ctl-dev-kit dashboard-manifest <src> <dist> write <dist>/dashboard.json from a fleet dashboard's calls
18
21
 
@@ -57,9 +60,13 @@ export function main(argv: string[]): void {
57
60
  newDashboard(rest);
58
61
  return;
59
62
  }
63
+ if (command === "create-demo") {
64
+ newDemo(rest);
65
+ return;
66
+ }
60
67
  if (command !== "create-product") {
61
68
  fail(
62
- `unknown command ${JSON.stringify(command ?? "")} — the commands are create-product, create-dashboard and dashboard-manifest.`,
69
+ `unknown command ${JSON.stringify(command ?? "")} — the commands are create-product, create-demo, create-dashboard and dashboard-manifest.`,
63
70
  );
64
71
  }
65
72
  const { values, positionals } = parseArgs({
@@ -172,3 +179,34 @@ Next steps (also in its README):
172
179
  3. norsk-mgr dashboard register ${values.name} && norsk-mgr dashboard upload ${values.name} dashboard.tar
173
180
  `);
174
181
  }
182
+
183
+ function newDemo(args: string[]): void {
184
+ const { values, positionals } = parseArgs({
185
+ args,
186
+ allowPositionals: true,
187
+ options: { name: { type: "string" }, with: { type: "string" }, user: { type: "string" } },
188
+ });
189
+ const dir = positionals[0];
190
+ if (!dir) fail("missing <dir>.");
191
+ if (!values.name) fail("missing --name.");
192
+ if (existsSync(dir) && readdirSync(dir).length > 0) fail(`${dir} exists and is not empty — refusing to overwrite.`);
193
+ const withFeatures = values.with
194
+ ?.split(",")
195
+ .map((f) => f.trim())
196
+ .filter(Boolean) as DemoFeature[] | undefined;
197
+ let files: string[];
198
+ try {
199
+ ({ files } = createDemo({ dir, name: values.name, user: values.user ?? process.env.USER ?? "me", withFeatures }));
200
+ } catch (err) {
201
+ fail(err instanceof Error ? err.message : String(err));
202
+ }
203
+ console.log(`Generated ${files.length} files into ${dir}.
204
+
205
+ Next steps (also in the generated CLAUDE.md):
206
+ 1. cd ${dir} && git init
207
+ 2. cp ctl/site.example.env ctl/site.env && cp ctl/studio.example.env ctl/studio.env # then fill them in
208
+ 3. ctl/install.sh # build, stage the working dir, launch instance ${values.name} in norsk-ctl
209
+ 4. scripts/test-feed.sh up # a test picture into the SRT input
210
+ 5. Replace the starter workflow in workdir/studio-save-files/${values.name}/ and ctl/push.sh after each change.
211
+ `);
212
+ }
@@ -608,7 +608,10 @@ export function exampleSidecar(args: ExampleArgs): WorkflowComponent<ExampleConf
608
608
  `;
609
609
  }
610
610
 
611
- export function componentsFiles(ctx: ShapeContext): GeneratedFile[] {
611
+ /** The components/ workspace alone — its build chain and the example node,
612
+ * without the product glue componentsFiles adds. create-demo uses this: a demo
613
+ * has no backend or shared/, and its build copies lib/ into the working dir. */
614
+ export function componentsWorkspaceFiles(ctx: ShapeContext): GeneratedFile[] {
612
615
  return [
613
616
  { path: "components/package.json", content: packageJson(ctx) },
614
617
  { path: "components/tsconfig.json", content: TSCONFIG },
@@ -621,6 +624,12 @@ export function componentsFiles(ctx: ShapeContext): GeneratedFile[] {
621
624
  { path: "components/src/library/example/types.source.yaml", content: EXAMPLE_TYPES_YAML },
622
625
  { path: "components/src/library/example/info.ts", content: exampleInfoTs(ctx) },
623
626
  { path: "components/src/library/example/runtime.ts", content: EXAMPLE_RUNTIME_TS },
627
+ ];
628
+ }
629
+
630
+ export function componentsFiles(ctx: ShapeContext): GeneratedFile[] {
631
+ return [
632
+ ...componentsWorkspaceFiles(ctx),
624
633
  { path: "backend/src/lib/components.ts", content: BACKEND_LIB_COMPONENTS_TS },
625
634
  { path: "shared/src/workflow/example-config.ts", content: exampleConfigTs(ctx) },
626
635
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.2.106",
3
+ "version": "0.2.108",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./create-product": "./create-product/create-product.ts",