@norskvideo/ctl-dev-kit 0.1.97 → 0.1.99
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.
- package/build/flake.lock +18 -1
- package/build/flake.nix +5 -5
- package/create-product/cli.ts +61 -21
- package/create-product/create-product.ts +103 -18
- package/create-product/features/components.ts +632 -0
- package/create-product/features/dashboard.ts +270 -0
- package/create-product/features/frontend.ts +176 -0
- package/create-product/features/views.ts +125 -0
- package/create-product/{backend-turnkey.ts → turnkey.ts} +504 -82
- package/package.json +1 -1
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// The `dashboard` feature: the operator screen as a standalone vite SPA that
|
|
2
|
+
// Studio serves per instance (funke's and probe's answer; see views.ts for the
|
|
3
|
+
// other one). Four different paths name the same thing, and conflating them is
|
|
4
|
+
// how a dashboard comes to build, pack and launch while serving a 404:
|
|
5
|
+
//
|
|
6
|
+
// repo (vite outDir) dashboards/<workflowName>/
|
|
7
|
+
// inside the tar dashboards/<workflowName>/
|
|
8
|
+
// inside the container studio-save-files/<workflowName>/dashboards/
|
|
9
|
+
// URL /dashboard/<workflowName>/
|
|
10
|
+
//
|
|
11
|
+
// The rename happens at ctl's mount, not here. The generated template ships
|
|
12
|
+
// workflow.yml at the tar root, so the workflow is called "workflow" and the
|
|
13
|
+
// directory below is not a free choice.
|
|
14
|
+
import type { GeneratedFile, ShapeContext } from "../create-product.ts";
|
|
15
|
+
import { EXAMPLE_ID } from "./components.ts";
|
|
16
|
+
|
|
17
|
+
/** The tar ships workflow.yml at its root, so Studio knows the workflow by this name. */
|
|
18
|
+
export const WORKFLOW_NAME = "workflow";
|
|
19
|
+
|
|
20
|
+
export const DASHBOARD_RUNTIME_SCREEN = {
|
|
21
|
+
url: `/dashboard/${WORKFLOW_NAME}/`,
|
|
22
|
+
label: "Open dashboard",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// The starter expose list omits this, which is the whole trap: the dashboard
|
|
26
|
+
// works on the direct Studio port and 404s through the oauth2 proxy.
|
|
27
|
+
export const DASHBOARD_PROXY_EXPOSE = "/dashboard/*";
|
|
28
|
+
|
|
29
|
+
function packageJson(ctx: ShapeContext): string {
|
|
30
|
+
return `${JSON.stringify(
|
|
31
|
+
{
|
|
32
|
+
name: `${ctx.scope}/dashboard`,
|
|
33
|
+
version: "0.0.1",
|
|
34
|
+
private: true,
|
|
35
|
+
type: "module",
|
|
36
|
+
description: `Per-workflow operator dashboard. Builds into ../dashboards/${WORKFLOW_NAME}, which the backend packs into the product-template tar and Studio serves at /dashboard/${WORKFLOW_NAME}/.`,
|
|
37
|
+
scripts: {
|
|
38
|
+
build: "bunx vite build",
|
|
39
|
+
dev: "bunx vite build --watch",
|
|
40
|
+
clean: `rm -rf ../dashboards/${WORKFLOW_NAME}`,
|
|
41
|
+
typecheck: "bunx tsc --noEmit",
|
|
42
|
+
},
|
|
43
|
+
dependencies: {
|
|
44
|
+
react: "^19.0.0",
|
|
45
|
+
"react-dom": "^19.0.0",
|
|
46
|
+
},
|
|
47
|
+
devDependencies: {
|
|
48
|
+
"@types/node": "^22.10.0",
|
|
49
|
+
"@types/react": "^19.0.10",
|
|
50
|
+
"@types/react-dom": "^19.0.3",
|
|
51
|
+
"@vitejs/plugin-react": "^6.0.1",
|
|
52
|
+
typescript: "^5.7.2",
|
|
53
|
+
vite: "^8.0.0",
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
null,
|
|
57
|
+
2,
|
|
58
|
+
)}\n`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// vite.config.ts is deliberately OUT of `include`: it imports vite itself, and
|
|
62
|
+
// the product's `bun run typecheck` should not need the bundler installed to
|
|
63
|
+
// check the app's own sources.
|
|
64
|
+
const TSCONFIG = `{
|
|
65
|
+
"compilerOptions": {
|
|
66
|
+
"target": "ESNext",
|
|
67
|
+
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
|
68
|
+
"module": "ESNext",
|
|
69
|
+
"moduleResolution": "bundler",
|
|
70
|
+
"strict": true,
|
|
71
|
+
"esModuleInterop": true,
|
|
72
|
+
"skipLibCheck": true,
|
|
73
|
+
"allowImportingTsExtensions": true,
|
|
74
|
+
"resolveJsonModule": true,
|
|
75
|
+
"isolatedModules": true,
|
|
76
|
+
"jsx": "react-jsx",
|
|
77
|
+
"noEmit": true
|
|
78
|
+
},
|
|
79
|
+
"include": ["src"]
|
|
80
|
+
}
|
|
81
|
+
`;
|
|
82
|
+
|
|
83
|
+
const VITE_CONFIG_TS = `import react from "@vitejs/plugin-react";
|
|
84
|
+
import { defineConfig } from "vite";
|
|
85
|
+
|
|
86
|
+
// Builds the operator dashboard into ../dashboards/${WORKFLOW_NAME}, which the
|
|
87
|
+
// backend packs into the product-template tar and Studio serves at
|
|
88
|
+
// /dashboard/${WORKFLOW_NAME}/.
|
|
89
|
+
//
|
|
90
|
+
// - \`base: "./"\` — the page is served behind a per-instance proxy prefix
|
|
91
|
+
// (/instance/<id>/…/dashboard/${WORKFLOW_NAME}/), so an absolute asset URL
|
|
92
|
+
// escapes the prefix and loads nothing.
|
|
93
|
+
// - the outDir name MUST equal the workflow name. The tar ships workflow.yml at
|
|
94
|
+
// its root, so the workflow is "${WORKFLOW_NAME}". Get this wrong and
|
|
95
|
+
// everything still builds, packs and launches — the dashboard just 404s.
|
|
96
|
+
export default defineConfig({
|
|
97
|
+
base: "./",
|
|
98
|
+
plugins: [react()],
|
|
99
|
+
build: {
|
|
100
|
+
outDir: "../dashboards/${WORKFLOW_NAME}",
|
|
101
|
+
emptyOutDir: true,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
`;
|
|
105
|
+
|
|
106
|
+
function indexHtml(ctx: ShapeContext): string {
|
|
107
|
+
return `<!doctype html>
|
|
108
|
+
<html lang="en">
|
|
109
|
+
<head>
|
|
110
|
+
<meta charset="UTF-8" />
|
|
111
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
112
|
+
<title>${ctx.name} — Operator</title>
|
|
113
|
+
</head>
|
|
114
|
+
<body>
|
|
115
|
+
<div id="root"></div>
|
|
116
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
117
|
+
</body>
|
|
118
|
+
</html>
|
|
119
|
+
`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const API_TS = `// The wire contract with the product's Studio components. The dashboard is a
|
|
123
|
+
// SEPARATE build from components/, so it codes against the HTTP/WS shape rather
|
|
124
|
+
// than importing the component's types.
|
|
125
|
+
//
|
|
126
|
+
// ADDRESSING. The page is served at /instance/<id>/…/dashboard/${WORKFLOW_NAME}/,
|
|
127
|
+
// and it cannot know that prefix at build time. Studio serves an \`env\` document
|
|
128
|
+
// beside it carrying the resolved bases, so everything here is discovered, never
|
|
129
|
+
// hardcoded — a root-absolute "/live/api/…" would resolve against the PROXY ROOT
|
|
130
|
+
// (the norsk-ctl UI) and never reach the instance.
|
|
131
|
+
|
|
132
|
+
export type Env = { studioUrlPrefix?: string; apiBasePath: string; wsBasePath: string };
|
|
133
|
+
|
|
134
|
+
// The placeholder component's instance id — the /live/api/<id>/… path segment.
|
|
135
|
+
// Mirrors EXAMPLE_ID in shared/src/workflow/ids.ts; duplicated deliberately,
|
|
136
|
+
// because this is a separate build coding to the wire shape.
|
|
137
|
+
const COMPONENT_ID = "${EXAMPLE_ID}";
|
|
138
|
+
|
|
139
|
+
/** Relative fetch: resolves against the directory this page is served from. */
|
|
140
|
+
export async function loadEnv(): Promise<Env> {
|
|
141
|
+
const r = await fetch("env");
|
|
142
|
+
if (!r.ok) throw new Error(\`env returned HTTP \${r.status}\`);
|
|
143
|
+
const body = (await r.json()) as Partial<Env>;
|
|
144
|
+
if (!body.apiBasePath || !body.wsBasePath) throw new Error("env missing apiBasePath/wsBasePath");
|
|
145
|
+
return body as Env;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function apiGet<T>(env: Env, path: string): Promise<T> {
|
|
149
|
+
const r = await fetch(\`\${env.apiBasePath}/\${COMPONENT_ID}\${path}\`);
|
|
150
|
+
if (!r.ok) throw new Error(\`GET \${path} -> HTTP \${r.status}\`);
|
|
151
|
+
return (await r.json()) as T;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export type ExampleState = { ticks: number };
|
|
155
|
+
|
|
156
|
+
export const getStatus = (env: Env) => apiGet<ExampleState>(env, "/status");
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The component's state feed: Studio broadcasts every updates.update() here.
|
|
160
|
+
*
|
|
161
|
+
* Studio REFUSES this socket while the component's state is undefined, which is
|
|
162
|
+
* why the component publishes from its constructor rather than on its first
|
|
163
|
+
* timer tick.
|
|
164
|
+
*/
|
|
165
|
+
export function openStateSocket(env: Env, onState: (state: ExampleState) => void): WebSocket {
|
|
166
|
+
const socket = new WebSocket(\`\${env.wsBasePath}/\${COMPONENT_ID}/ws\`);
|
|
167
|
+
socket.onmessage = (event) => {
|
|
168
|
+
onState(JSON.parse(event.data as string) as ExampleState);
|
|
169
|
+
};
|
|
170
|
+
return socket;
|
|
171
|
+
}
|
|
172
|
+
`;
|
|
173
|
+
|
|
174
|
+
const APP_TSX = `// PLACEHOLDER operator dashboard — it renders the heartbeat the placeholder
|
|
175
|
+
// component publishes, which is enough to prove the whole path: the tar packed
|
|
176
|
+
// this page, Studio served it, \`env\` resolved the bases, and the component's
|
|
177
|
+
// state socket delivered. Replace the body; keep the shape.
|
|
178
|
+
import { useEffect, useState } from "react";
|
|
179
|
+
import { type Env, type ExampleState, openStateSocket } from "./api.ts";
|
|
180
|
+
|
|
181
|
+
export default function App({ env }: { env: Env }) {
|
|
182
|
+
const [state, setState] = useState<ExampleState | undefined>();
|
|
183
|
+
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
const socket = openStateSocket(env, setState);
|
|
186
|
+
return () => socket.close();
|
|
187
|
+
}, [env]);
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<main>
|
|
191
|
+
<h1>Operator</h1>
|
|
192
|
+
<p>
|
|
193
|
+
Placeholder dashboard — replace it with this product's operator screen, or drop the dashboard workspace if the
|
|
194
|
+
product does not need one.
|
|
195
|
+
</p>
|
|
196
|
+
<p className="tick">
|
|
197
|
+
{state === undefined ? "waiting for the component's first state…" : \`\${state.ticks} ticks\`}
|
|
198
|
+
</p>
|
|
199
|
+
</main>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
`;
|
|
203
|
+
|
|
204
|
+
const MAIN_TSX = `import React from "react";
|
|
205
|
+
import ReactDOM from "react-dom/client";
|
|
206
|
+
import App from "./App.tsx";
|
|
207
|
+
import { loadEnv } from "./api.ts";
|
|
208
|
+
import "./index.css";
|
|
209
|
+
|
|
210
|
+
// env first, everything else after: the bases every request needs are only
|
|
211
|
+
// knowable at runtime (see api.ts).
|
|
212
|
+
const env = await loadEnv();
|
|
213
|
+
|
|
214
|
+
const root = document.getElementById("root");
|
|
215
|
+
if (!root) throw new Error("root element missing");
|
|
216
|
+
|
|
217
|
+
ReactDOM.createRoot(root).render(
|
|
218
|
+
<React.StrictMode>
|
|
219
|
+
<App env={env} />
|
|
220
|
+
</React.StrictMode>,
|
|
221
|
+
);
|
|
222
|
+
`;
|
|
223
|
+
|
|
224
|
+
const INDEX_CSS = `:root {
|
|
225
|
+
color-scheme: light dark;
|
|
226
|
+
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
body {
|
|
230
|
+
margin: 0;
|
|
231
|
+
padding: 2rem;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.tick {
|
|
235
|
+
font-variant-numeric: tabular-nums;
|
|
236
|
+
font-size: 1.5rem;
|
|
237
|
+
}
|
|
238
|
+
`;
|
|
239
|
+
|
|
240
|
+
const BACKEND_LIB_DASHBOARDS_TS = `// The built dashboard files for the product-template tar. Studio serves them at
|
|
241
|
+
// /dashboard/<workflowName>/ once the template mounts; this backend does not
|
|
242
|
+
// serve them itself. DASHBOARDS_ROOT is the dashboard/ workspace's vite outDir
|
|
243
|
+
// (gitignored build output). The root is ours; the reader is
|
|
244
|
+
// @norskvideo/ctl-sdk's.
|
|
245
|
+
import path from "node:path";
|
|
246
|
+
import { loadDashboardFiles as load } from "@norskvideo/ctl-sdk";
|
|
247
|
+
|
|
248
|
+
export const DASHBOARDS_ROOT = path.resolve(import.meta.dir, "../../../dashboards");
|
|
249
|
+
|
|
250
|
+
export const loadDashboardFiles = (root: string = DASHBOARDS_ROOT) => load(root);
|
|
251
|
+
`;
|
|
252
|
+
|
|
253
|
+
export function dashboardFiles(ctx: ShapeContext): GeneratedFile[] {
|
|
254
|
+
return [
|
|
255
|
+
{ path: "dashboard/package.json", content: packageJson(ctx) },
|
|
256
|
+
{ path: "dashboard/tsconfig.json", content: TSCONFIG },
|
|
257
|
+
{ path: "dashboard/vite.config.ts", content: VITE_CONFIG_TS },
|
|
258
|
+
{ path: "dashboard/index.html", content: indexHtml(ctx) },
|
|
259
|
+
{ path: "dashboard/src/main.tsx", content: MAIN_TSX },
|
|
260
|
+
{ path: "dashboard/src/App.tsx", content: APP_TSX },
|
|
261
|
+
{ path: "dashboard/src/api.ts", content: API_TS },
|
|
262
|
+
{ path: "dashboard/src/index.css", content: INDEX_CSS },
|
|
263
|
+
{ path: "backend/src/lib/dashboards.ts", content: BACKEND_LIB_DASHBOARDS_TS },
|
|
264
|
+
];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export const DASHBOARD_GITIGNORE = `# Vite output of the dashboard/ workspace (outDir ../dashboards/${WORKFLOW_NAME}).
|
|
268
|
+
# Regenerated by \`bun run build:dashboard\`; packed by the backend at runtime.
|
|
269
|
+
dashboards/
|
|
270
|
+
`;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// The `frontend` feature: the product-level configure screen, served by the
|
|
2
|
+
// PRODUCT container at /configure and iframed by ctl at
|
|
3
|
+
// manifest.ui.configScreenUrl.
|
|
4
|
+
//
|
|
5
|
+
// Not to be confused with either operator screen (views.ts, dashboard.ts):
|
|
6
|
+
// those are per-INSTANCE and served by Studio. This one is served once, by the
|
|
7
|
+
// single control-plane container the daemon runs, and its job is to collect the
|
|
8
|
+
// config a product-template is built from.
|
|
9
|
+
//
|
|
10
|
+
// The trap is registration-time. The daemon probes configScreenUrl and refuses
|
|
11
|
+
// the product with CONFIG_SCREEN_UNREACHABLE unless it answers 2xx — which is
|
|
12
|
+
// why declaring it, serving it and building it are one feature rather than
|
|
13
|
+
// three independent edits, and why the dev script builds dist before starting
|
|
14
|
+
// the backend that serves it.
|
|
15
|
+
import type { GeneratedFile, ShapeContext } from "../create-product.ts";
|
|
16
|
+
|
|
17
|
+
export const CONFIG_SCREEN_URL = "/configure";
|
|
18
|
+
|
|
19
|
+
function packageJson(ctx: ShapeContext): string {
|
|
20
|
+
return `${JSON.stringify(
|
|
21
|
+
{
|
|
22
|
+
name: `${ctx.scope}/frontend`,
|
|
23
|
+
version: "0.0.1",
|
|
24
|
+
private: true,
|
|
25
|
+
type: "module",
|
|
26
|
+
scripts: {
|
|
27
|
+
// Vite's own dev server, for iterating on the screen with HMR. The
|
|
28
|
+
// BACKEND serves dist/, so this is not what a registered dev product
|
|
29
|
+
// shows — see the root `dev` script.
|
|
30
|
+
dev: "bunx vite",
|
|
31
|
+
build: "bunx vite build",
|
|
32
|
+
clean: "rm -rf dist",
|
|
33
|
+
typecheck: "bunx tsc --noEmit",
|
|
34
|
+
},
|
|
35
|
+
dependencies: {
|
|
36
|
+
[`${ctx.scope}/shared`]: "workspace:*",
|
|
37
|
+
react: "^19.0.0",
|
|
38
|
+
"react-dom": "^19.0.0",
|
|
39
|
+
},
|
|
40
|
+
devDependencies: {
|
|
41
|
+
"@types/node": "^22.10.0",
|
|
42
|
+
"@types/react": "^19.0.10",
|
|
43
|
+
"@types/react-dom": "^19.0.3",
|
|
44
|
+
"@vitejs/plugin-react": "^6.0.1",
|
|
45
|
+
typescript: "^5.7.2",
|
|
46
|
+
vite: "^8.0.0",
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
null,
|
|
50
|
+
2,
|
|
51
|
+
)}\n`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// vite.config.ts stays out of `include` — it imports vite itself, and checking
|
|
55
|
+
// the app's own sources should not need the bundler installed.
|
|
56
|
+
const TSCONFIG = `{
|
|
57
|
+
"compilerOptions": {
|
|
58
|
+
"target": "ESNext",
|
|
59
|
+
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
|
60
|
+
"module": "ESNext",
|
|
61
|
+
"moduleResolution": "bundler",
|
|
62
|
+
"strict": true,
|
|
63
|
+
"esModuleInterop": true,
|
|
64
|
+
"skipLibCheck": true,
|
|
65
|
+
"allowImportingTsExtensions": true,
|
|
66
|
+
"resolveJsonModule": true,
|
|
67
|
+
"isolatedModules": true,
|
|
68
|
+
"jsx": "react-jsx",
|
|
69
|
+
"noEmit": true
|
|
70
|
+
},
|
|
71
|
+
"include": ["src"]
|
|
72
|
+
}
|
|
73
|
+
`;
|
|
74
|
+
|
|
75
|
+
const VITE_CONFIG_TS = `import react from "@vitejs/plugin-react";
|
|
76
|
+
import { defineConfig } from "vite";
|
|
77
|
+
|
|
78
|
+
// \`base: "./"\` — the screen is iframed under the product's own path prefix in
|
|
79
|
+
// the norsk-ctl UI, so absolute asset URLs escape it.
|
|
80
|
+
export default defineConfig({
|
|
81
|
+
base: "./",
|
|
82
|
+
plugins: [react()],
|
|
83
|
+
build: { outDir: "dist", emptyOutDir: true },
|
|
84
|
+
});
|
|
85
|
+
`;
|
|
86
|
+
|
|
87
|
+
function indexHtml(ctx: ShapeContext): string {
|
|
88
|
+
return `<!doctype html>
|
|
89
|
+
<html lang="en">
|
|
90
|
+
<head>
|
|
91
|
+
<meta charset="UTF-8" />
|
|
92
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
93
|
+
<title>${ctx.name} — configure</title>
|
|
94
|
+
</head>
|
|
95
|
+
<body>
|
|
96
|
+
<div id="root"></div>
|
|
97
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
98
|
+
</body>
|
|
99
|
+
</html>
|
|
100
|
+
`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const MAIN_TSX = `import React from "react";
|
|
104
|
+
import ReactDOM from "react-dom/client";
|
|
105
|
+
import App from "./App.tsx";
|
|
106
|
+
import "./index.css";
|
|
107
|
+
|
|
108
|
+
const root = document.getElementById("root");
|
|
109
|
+
if (!root) throw new Error("root element missing");
|
|
110
|
+
|
|
111
|
+
ReactDOM.createRoot(root).render(
|
|
112
|
+
<React.StrictMode>
|
|
113
|
+
<App />
|
|
114
|
+
</React.StrictMode>,
|
|
115
|
+
);
|
|
116
|
+
`;
|
|
117
|
+
|
|
118
|
+
function appTsx(ctx: ShapeContext): string {
|
|
119
|
+
return `// PLACEHOLDER configure screen. The starter template needs no configuration,
|
|
120
|
+
// so this says so rather than pretending to collect something.
|
|
121
|
+
//
|
|
122
|
+
// When the product grows real config: POST the ProductConfig this screen
|
|
123
|
+
// collects to /api/product-template and the backend returns the built tar.
|
|
124
|
+
// ProductConfigSchema (shared/src/schemas/config.ts) is the contract for what
|
|
125
|
+
// belongs here — it is the same schema the route validates against, so this
|
|
126
|
+
// workspace depends on ${ctx.scope}/shared rather than restating the shape.
|
|
127
|
+
export default function App() {
|
|
128
|
+
return (
|
|
129
|
+
<main>
|
|
130
|
+
<h1>${ctx.name}</h1>
|
|
131
|
+
<p>
|
|
132
|
+
The starter template needs no configuration. Launch it from norsk-ctl to bring up the Studio + Norsk stack this
|
|
133
|
+
product composes.
|
|
134
|
+
</p>
|
|
135
|
+
<p>
|
|
136
|
+
Replace this screen when the product grows launch-time config, or drop the frontend workspace if it never does.
|
|
137
|
+
</p>
|
|
138
|
+
</main>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const INDEX_CSS = `:root {
|
|
145
|
+
color-scheme: light dark;
|
|
146
|
+
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
body {
|
|
150
|
+
margin: 0;
|
|
151
|
+
padding: 2rem;
|
|
152
|
+
}
|
|
153
|
+
`;
|
|
154
|
+
|
|
155
|
+
export function frontendFiles(ctx: ShapeContext): GeneratedFile[] {
|
|
156
|
+
return [
|
|
157
|
+
{ path: "frontend/package.json", content: packageJson(ctx) },
|
|
158
|
+
{ path: "frontend/tsconfig.json", content: TSCONFIG },
|
|
159
|
+
{ path: "frontend/vite.config.ts", content: VITE_CONFIG_TS },
|
|
160
|
+
{ path: "frontend/index.html", content: indexHtml(ctx) },
|
|
161
|
+
{ path: "frontend/src/main.tsx", content: MAIN_TSX },
|
|
162
|
+
{ path: "frontend/src/App.tsx", content: appTsx(ctx) },
|
|
163
|
+
{ path: "frontend/src/index.css", content: INDEX_CSS },
|
|
164
|
+
];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The express block serving the built screen at configScreenUrl. */
|
|
168
|
+
export const FRONTEND_SERVER_BLOCK = ` // The configure screen at manifest.ui.configScreenUrl. The daemon probes this
|
|
169
|
+
// at \`product add\` and refuses the product with CONFIG_SCREEN_UNREACHABLE if
|
|
170
|
+
// it does not answer 2xx — so it must be BUILT (\`bun run build:frontend\`,
|
|
171
|
+
// which the image build and the root \`dev\` script both do) before the
|
|
172
|
+
// product is registered.
|
|
173
|
+
app.use("${CONFIG_SCREEN_URL}", express.static(FRONTEND_DIST));
|
|
174
|
+
app.get("${CONFIG_SCREEN_URL}/*splat", (_req, res) => {
|
|
175
|
+
res.sendFile(path.join(FRONTEND_DIST, "index.html"));
|
|
176
|
+
});`;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// The `views` feature: the operator screen as React rendered INSIDE Studio's
|
|
2
|
+
// own workflow view — one file per place Studio shows a component: `inline`
|
|
3
|
+
// inside its node on the canvas, `summary` in the sidebar when the node is
|
|
4
|
+
// selected, and a `fullscreen` page at /live/<id>/fullscreen — rather than as a
|
|
5
|
+
// standalone SPA (see dashboard.ts for that answer).
|
|
6
|
+
//
|
|
7
|
+
// It costs no vite workspace and no dashboards/ directory: the screens are
|
|
8
|
+
// compiled into the component's info.client.js by copy-yamls. What it does
|
|
9
|
+
// cost is the browser-bundle constraints — Studio's loader has no import map,
|
|
10
|
+
// so react is rewritten to a window global and nothing here may reach for a
|
|
11
|
+
// bare specifier the loader cannot resolve.
|
|
12
|
+
//
|
|
13
|
+
// State arrives through Studio's own plumbing (ViewProps.state, updated from
|
|
14
|
+
// every updates.update() the runtime publishes) and commands go back through
|
|
15
|
+
// sendCommand, so a view never fetches and never opens a socket.
|
|
16
|
+
import type { GeneratedFile, ShapeContext } from "../create-product.ts";
|
|
17
|
+
|
|
18
|
+
const INLINE_TSX = `// PLACEHOLDER — rendered INSIDE the component's node on the canvas. Keep it to a
|
|
19
|
+
// line or two: the node is small, and without this view the node shows only its
|
|
20
|
+
// static config. The sidebar is summary.tsx; the full page is fullscreen.tsx.
|
|
21
|
+
//
|
|
22
|
+
// \`state\` is pushed by Studio from the component's updates.update() calls; this
|
|
23
|
+
// file neither fetches nor subscribes.
|
|
24
|
+
import type { ViewProps } from "@norskvideo/norsk-studio/lib/extension/client-types";
|
|
25
|
+
import type { ExampleCommand, ExampleSettings, ExampleState } from "./runtime";
|
|
26
|
+
|
|
27
|
+
function InlineView({ state }: ViewProps<ExampleSettings, ExampleState, ExampleCommand>) {
|
|
28
|
+
return (
|
|
29
|
+
<div className="text-sm text-gray-700 dark:text-gray-300">
|
|
30
|
+
<span className="font-medium text-gray-900 dark:text-white tabular-nums">{state.ticks}</span> ticks
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default InlineView;
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
const SUMMARY_TSX = `// PLACEHOLDER — rendered in Studio's SIDEBAR when the component's node is
|
|
39
|
+
// selected. More room than the node itself (inline.tsx); the fullscreen view is
|
|
40
|
+
// still where full detail belongs.
|
|
41
|
+
//
|
|
42
|
+
// \`state\` is pushed by Studio from the component's updates.update() calls; this
|
|
43
|
+
// file neither fetches nor subscribes.
|
|
44
|
+
import type { ViewProps } from "@norskvideo/norsk-studio/lib/extension/client-types";
|
|
45
|
+
import type { ExampleCommand, ExampleSettings, ExampleState } from "./runtime";
|
|
46
|
+
|
|
47
|
+
function SummaryView({ state, config }: ViewProps<ExampleSettings, ExampleState, ExampleCommand>) {
|
|
48
|
+
return (
|
|
49
|
+
<dl className="grid grid-cols-2 gap-1 text-sm">
|
|
50
|
+
<dt className="text-gray-500 dark:text-gray-400">Heartbeats</dt>
|
|
51
|
+
<dd className="tabular-nums text-gray-900 dark:text-white">{state.ticks}</dd>
|
|
52
|
+
<dt className="text-gray-500 dark:text-gray-400">Interval</dt>
|
|
53
|
+
<dd className="tabular-nums text-gray-900 dark:text-white">{config.heartbeatMs ?? 1000} ms</dd>
|
|
54
|
+
</dl>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export default SummaryView;
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
const FULLSCREEN_TSX = `// PLACEHOLDER — the operator screen, reached at /live/<id>/fullscreen (and via
|
|
62
|
+
// the template manifest's runtimeScreenUrl, which points at Studio). This is
|
|
63
|
+
// where a real product's desk goes: oracle's highlights desk is this file.
|
|
64
|
+
//
|
|
65
|
+
// Two things worth keeping when you replace it:
|
|
66
|
+
// - state comes from ViewProps and nothing else, so the screen cannot drift
|
|
67
|
+
// from what the runtime actually published;
|
|
68
|
+
// - commands go back through sendCommand, which Studio routes to the
|
|
69
|
+
// component's handleCommand — never through an HTTP call of your own.
|
|
70
|
+
import type { ViewProps } from "@norskvideo/norsk-studio/lib/extension/client-types";
|
|
71
|
+
import type { ExampleCommand, ExampleSettings, ExampleState } from "./runtime";
|
|
72
|
+
|
|
73
|
+
function FullscreenView({ state, config }: ViewProps<ExampleSettings, ExampleState, ExampleCommand>) {
|
|
74
|
+
return (
|
|
75
|
+
<div className="p-6 space-y-4">
|
|
76
|
+
<header>
|
|
77
|
+
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">{config.displayName}</h1>
|
|
78
|
+
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
79
|
+
Placeholder operator screen — replace it with this product's desk, or delete it with the component.
|
|
80
|
+
</p>
|
|
81
|
+
</header>
|
|
82
|
+
<dl className="grid grid-cols-2 gap-2 max-w-sm">
|
|
83
|
+
<dt className="text-gray-500 dark:text-gray-400">Heartbeats</dt>
|
|
84
|
+
<dd className="tabular-nums text-gray-900 dark:text-white">{state.ticks}</dd>
|
|
85
|
+
<dt className="text-gray-500 dark:text-gray-400">Interval</dt>
|
|
86
|
+
<dd className="tabular-nums text-gray-900 dark:text-white">{config.heartbeatMs ?? 1000} ms</dd>
|
|
87
|
+
</dl>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export default FullscreenView;
|
|
93
|
+
`;
|
|
94
|
+
|
|
95
|
+
export function viewsFiles(_ctx: ShapeContext): GeneratedFile[] {
|
|
96
|
+
return [
|
|
97
|
+
{ path: "components/src/library/example/inline.tsx", content: INLINE_TSX },
|
|
98
|
+
{ path: "components/src/library/example/summary.tsx", content: SUMMARY_TSX },
|
|
99
|
+
{ path: "components/src/library/example/fullscreen.tsx", content: FULLSCREEN_TSX },
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Imports the component's info.ts needs when it registers the three views. Kept
|
|
104
|
+
* separate because biome sorts them around the ./runtime import between them. */
|
|
105
|
+
export const VIEWS_INFO_IMPORTS = {
|
|
106
|
+
fullscreen: 'import FullscreenView from "./fullscreen";',
|
|
107
|
+
inline: 'import InlineView from "./inline";',
|
|
108
|
+
summary: 'import SummaryView from "./summary";',
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** The `runtime:` entries registering them. */
|
|
112
|
+
export const VIEWS_INFO_RUNTIME = ` inline: InlineView,
|
|
113
|
+
summary: SummaryView,
|
|
114
|
+
fullscreen: FullscreenView,
|
|
115
|
+
`;
|
|
116
|
+
|
|
117
|
+
// Studio owns the whole per-instance surface here, so the runtime screen IS
|
|
118
|
+
// Studio: ctl anchors studio_url_prefix on its own /studio/ base route and the
|
|
119
|
+
// component's page resolves under it as /instance/<id>/studio/live/<id>/fullscreen.
|
|
120
|
+
export const VIEWS_RUNTIME_SCREEN = { url: "/studio/", label: "Open Studio" };
|
|
121
|
+
|
|
122
|
+
// /live/api/* covers the component's ROUTES; the per-component PAGES and their
|
|
123
|
+
// state websockets are /live/<id>/fullscreen and /live/<id>/ws, so the wider
|
|
124
|
+
// entry is required or the screen 404s through the proxy while working direct.
|
|
125
|
+
export const VIEWS_PROXY_EXPOSE = "/live/*";
|