@norskvideo/ctl-dev-kit 0.1.97 → 0.1.98
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 +97 -0
- package/create-product/{backend-turnkey.ts → turnkey.ts} +501 -82
- package/package.json +1 -1
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
// The `components` feature: the components/ workspace whose compiled output is
|
|
2
|
+
// packed into the product-template tar as a Studio custom-node library.
|
|
3
|
+
//
|
|
4
|
+
// The build is three steps and the ORDER is load-bearing:
|
|
5
|
+
// 1. codegen.ts types.source.yaml -> _gen/types.ts (pre-tsc)
|
|
6
|
+
// 2. tsc src/**.ts -> lib/**.js (CommonJS)
|
|
7
|
+
// 3. copy-yamls the per-library package.json, each component's types.yaml
|
|
8
|
+
// next to its compiled runtime.js, and the BROWSER bundle
|
|
9
|
+
// info.client.js (post-tsc)
|
|
10
|
+
//
|
|
11
|
+
// Sources reconciled from the fleet: codegen uses @norskvideo/ctl-oas-to-ts
|
|
12
|
+
// (ours, and it emits the schema key verbatim rather than PascalCasing it);
|
|
13
|
+
// copy-yamls is funke's, the only one carrying the external-globals plugin
|
|
14
|
+
// that keeps react out of the browser bundle.
|
|
15
|
+
import type { GeneratedFile, ShapeContext } from "../create-product.ts";
|
|
16
|
+
import { VIEWS_INFO_IMPORTS, VIEWS_INFO_RUNTIME } from "./views.ts";
|
|
17
|
+
|
|
18
|
+
// The identifier the placeholder registers under, referenced from three
|
|
19
|
+
// generated files (the stub, the composer, the component itself).
|
|
20
|
+
export const EXAMPLE_IDENTIFIER = "output.example";
|
|
21
|
+
export const EXAMPLE_ID = "example";
|
|
22
|
+
|
|
23
|
+
/** `@<product>/library` — the package name Studio resolves the mounted library by. */
|
|
24
|
+
export const libraryPackage = (ctx: ShapeContext): string => `${ctx.scope}/library`;
|
|
25
|
+
|
|
26
|
+
function packageJson(ctx: ShapeContext): string {
|
|
27
|
+
return `${JSON.stringify(
|
|
28
|
+
{
|
|
29
|
+
name: `${ctx.scope}/components`,
|
|
30
|
+
version: "0.0.1",
|
|
31
|
+
private: true,
|
|
32
|
+
// CommonJS: Studio require()s the compiled component at load.
|
|
33
|
+
type: "commonjs",
|
|
34
|
+
main: "lib/index.js",
|
|
35
|
+
scripts: {
|
|
36
|
+
build: "bun run scripts/codegen.ts && bunx tsc && bun run scripts/copy-yamls.ts",
|
|
37
|
+
codegen: "bun run scripts/codegen.ts",
|
|
38
|
+
clean: "rm -rf lib",
|
|
39
|
+
test: "bun test src/",
|
|
40
|
+
// _gen/types.ts is gitignored build output, so tsc has nothing to check
|
|
41
|
+
// against on a clean checkout until codegen has run.
|
|
42
|
+
pretypecheck: "bun run codegen",
|
|
43
|
+
typecheck: "bunx tsc --noEmit",
|
|
44
|
+
},
|
|
45
|
+
dependencies: {
|
|
46
|
+
"@norskvideo/ctl-oas-to-ts": "^0.1.0",
|
|
47
|
+
react: "^19.0.0",
|
|
48
|
+
zod: "^4.3.6",
|
|
49
|
+
},
|
|
50
|
+
devDependencies: {
|
|
51
|
+
"@norskvideo/norsk-sdk": ctx.pins.mediaLib,
|
|
52
|
+
"@norskvideo/norsk-studio": ctx.pins.studioLib,
|
|
53
|
+
"@types/bun": "latest",
|
|
54
|
+
"@types/express": "^5.0.0",
|
|
55
|
+
"@types/node": "^22.10.0",
|
|
56
|
+
"@types/react": "^19.0.10",
|
|
57
|
+
typescript: "^5.7.2",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
null,
|
|
61
|
+
2,
|
|
62
|
+
)}\n`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// NOT the shared tsconfig.base.json: this workspace is the one CommonJS island
|
|
66
|
+
// in an otherwise ESM repo, and the module setting below is the reason.
|
|
67
|
+
const TSCONFIG = `{
|
|
68
|
+
"compilerOptions": {
|
|
69
|
+
"target": "ES2022",
|
|
70
|
+
// CommonJS so tsc rewrites \`await import()\` to \`require + __importStar\`,
|
|
71
|
+
// which respects \`__esModule:true\` on CJS modules. node16 would preserve a
|
|
72
|
+
// native dynamic \`import()\` and Node's CJS-via-ESM interop double-wraps the
|
|
73
|
+
// exports object, so \`infoMod.default\` becomes the wrapper, not the factory.
|
|
74
|
+
"module": "commonjs",
|
|
75
|
+
"moduleResolution": "node",
|
|
76
|
+
"outDir": "./lib",
|
|
77
|
+
"rootDir": "./src",
|
|
78
|
+
"strict": true,
|
|
79
|
+
"esModuleInterop": true,
|
|
80
|
+
"skipLibCheck": true,
|
|
81
|
+
"removeComments": true,
|
|
82
|
+
"declaration": true,
|
|
83
|
+
"jsx": "react-jsx",
|
|
84
|
+
"resolveJsonModule": true,
|
|
85
|
+
"sourceMap": true
|
|
86
|
+
},
|
|
87
|
+
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
|
88
|
+
"exclude": ["node_modules", "lib", "**/*.test.ts", "**/*.test.tsx"]
|
|
89
|
+
}
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
const CODEGEN_TS = `// Pre-tsc step: regenerate \`_gen/types.ts\` for each component from its
|
|
93
|
+
// \`types.source.yaml\`.
|
|
94
|
+
//
|
|
95
|
+
// The YAML is the single source of truth for the OpenAPI shape; a component's
|
|
96
|
+
// modules alias the generated types, so if the code builds a shape the schema
|
|
97
|
+
// does not describe, tsc fails. The same file ships alongside the built
|
|
98
|
+
// component as \`types.yaml\` (see copy-yamls.ts) for Studio's runtime, which is
|
|
99
|
+
// why it stays YAML rather than becoming zod like norsk-ctl's own contract.
|
|
100
|
+
//
|
|
101
|
+
// Hand-edits to \`_gen/types.ts\` are clobbered — edit the YAML instead.
|
|
102
|
+
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
103
|
+
import { dirname, join, relative } from "node:path";
|
|
104
|
+
import { schemasToTypeScript } from "@norskvideo/ctl-oas-to-ts";
|
|
105
|
+
|
|
106
|
+
const SRC_ROOT = join(import.meta.dir, "..", "src");
|
|
107
|
+
|
|
108
|
+
function* walk(dir: string): Generator<string> {
|
|
109
|
+
for (const entry of readdirSync(dir)) {
|
|
110
|
+
const full = join(dir, entry);
|
|
111
|
+
if (statSync(full).isDirectory()) {
|
|
112
|
+
yield* walk(full);
|
|
113
|
+
} else if (full.endsWith("types.source.yaml")) {
|
|
114
|
+
yield full;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const HEADER = "// AUTO-GENERATED. Edit \`types.source.yaml\` and re-run \`bun run build\`.\\n\\n";
|
|
120
|
+
|
|
121
|
+
let gen = 0;
|
|
122
|
+
for (const sourcePath of walk(SRC_ROOT)) {
|
|
123
|
+
const rel = relative(SRC_ROOT, sourcePath);
|
|
124
|
+
const outDir = join(dirname(sourcePath), "_gen");
|
|
125
|
+
const outPath = join(outDir, "types.ts");
|
|
126
|
+
|
|
127
|
+
const spec = Bun.YAML.parse(readFileSync(sourcePath, "utf-8")) as {
|
|
128
|
+
components?: { schemas?: Record<string, unknown> };
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// \`_gen/\` holds nothing but generated files and is gitignored, so on a clean
|
|
132
|
+
// checkout it does not exist yet.
|
|
133
|
+
mkdirSync(outDir, { recursive: true });
|
|
134
|
+
writeFileSync(outPath, schemasToTypeScript(spec.components?.schemas ?? {}, { header: HEADER }));
|
|
135
|
+
|
|
136
|
+
console.log(\` \${rel} -> \${relative(process.cwd(), outPath)}\`);
|
|
137
|
+
gen++;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
console.log(\`generated \${gen} type file(s)\`);
|
|
141
|
+
`;
|
|
142
|
+
|
|
143
|
+
const COPY_YAMLS_TS = `// Post-tsc step. For each library under src/ (a subdir shipping an index.ts),
|
|
144
|
+
// and each component inside it (a subdir shipping an info.ts), emit the runtime
|
|
145
|
+
// artifacts Studio needs alongside the compiled JS: the per-library
|
|
146
|
+
// package.json (anchoring Node's CJS resolution at the library root), each
|
|
147
|
+
// component's types.yaml, and a browser-targeted info.client.js bundle.
|
|
148
|
+
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
149
|
+
import { dirname, join, relative } from "node:path";
|
|
150
|
+
import type { BunPlugin } from "bun";
|
|
151
|
+
|
|
152
|
+
const SRC_ROOT = join(import.meta.dir, "..", "src");
|
|
153
|
+
const LIB_ROOT = join(import.meta.dir, "..", "lib");
|
|
154
|
+
|
|
155
|
+
// Studio's browser plugin loader (@norskvideo/norsk-studio/ui/bypass-esbuild.js)
|
|
156
|
+
// does a raw import() with NO import map, so a bare specifier left in the bundle
|
|
157
|
+
// is a fatal module-resolution crash in the workflow view. Studio's own bundler
|
|
158
|
+
// inlines @norskvideo/* and rewrites react / react/jsx-runtime (plus the webrtc
|
|
159
|
+
// and hls clients) to window globals injected by the Studio shell — it never
|
|
160
|
+
// bundles a second copy of React. Replicate that mapping here.
|
|
161
|
+
const EXTERNAL_GLOBALS: Record<string, string> = {
|
|
162
|
+
react: "window.ReactExports",
|
|
163
|
+
"react/jsx-runtime": "window.ReactJsx",
|
|
164
|
+
"@norskvideo/webrtc-client": "window.WebRtcClient",
|
|
165
|
+
"hls.js": "window.HlsJs",
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const EXTERNAL_GLOBAL_NAMESPACE = "external-global";
|
|
169
|
+
|
|
170
|
+
const externalGlobalPlugin: BunPlugin = {
|
|
171
|
+
name: "external-global",
|
|
172
|
+
setup(build) {
|
|
173
|
+
for (const specifier of Object.keys(EXTERNAL_GLOBALS)) {
|
|
174
|
+
const filter = new RegExp(\`^\${specifier.replace(/[.*+?^\${}()|[\\]\\\\]/g, "\\\\$&")}$\`);
|
|
175
|
+
build.onResolve({ filter }, (args) => ({ path: args.path, namespace: EXTERNAL_GLOBAL_NAMESPACE }));
|
|
176
|
+
}
|
|
177
|
+
build.onLoad({ filter: /.*/, namespace: EXTERNAL_GLOBAL_NAMESPACE }, (args) => ({
|
|
178
|
+
contents: \`module.exports = \${EXTERNAL_GLOBALS[args.path]};\`,
|
|
179
|
+
loader: "js",
|
|
180
|
+
}));
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
function isFile(p: string): boolean {
|
|
185
|
+
try {
|
|
186
|
+
return statSync(p).isFile();
|
|
187
|
+
} catch {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function listSubdirs(dir: string): string[] {
|
|
193
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
194
|
+
.filter((e) => e.isDirectory())
|
|
195
|
+
.map((e) => e.name);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const libraries = listSubdirs(SRC_ROOT).filter((name) => isFile(join(SRC_ROOT, name, "index.ts")));
|
|
199
|
+
|
|
200
|
+
if (libraries.length === 0) {
|
|
201
|
+
console.warn("no libraries found under src/ — nothing to do");
|
|
202
|
+
process.exit(0);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let copiedYamls = 0;
|
|
206
|
+
let bundledClients = 0;
|
|
207
|
+
let writtenPackages = 0;
|
|
208
|
+
|
|
209
|
+
for (const library of libraries) {
|
|
210
|
+
const libSrc = join(SRC_ROOT, library);
|
|
211
|
+
const libOut = join(LIB_ROOT, library);
|
|
212
|
+
const components = listSubdirs(libSrc).filter((name) => isFile(join(libSrc, name, "info.ts")));
|
|
213
|
+
|
|
214
|
+
mkdirSync(libOut, { recursive: true });
|
|
215
|
+
const pkgPath = join(libOut, "package.json");
|
|
216
|
+
writeFileSync(
|
|
217
|
+
pkgPath,
|
|
218
|
+
\`\${JSON.stringify(
|
|
219
|
+
{
|
|
220
|
+
name: \`__SCOPE__/\${library}\`,
|
|
221
|
+
version: "0.0.0",
|
|
222
|
+
private: true,
|
|
223
|
+
type: "commonjs",
|
|
224
|
+
main: "index.js",
|
|
225
|
+
},
|
|
226
|
+
null,
|
|
227
|
+
2,
|
|
228
|
+
)}\\n\`,
|
|
229
|
+
);
|
|
230
|
+
writtenPackages++;
|
|
231
|
+
|
|
232
|
+
for (const component of components) {
|
|
233
|
+
const compSrc = join(libSrc, component);
|
|
234
|
+
const compOut = join(libOut, component);
|
|
235
|
+
|
|
236
|
+
const yamlSrc = join(compSrc, "types.source.yaml");
|
|
237
|
+
if (isFile(yamlSrc)) {
|
|
238
|
+
const yamlOut = join(compOut, "types.yaml");
|
|
239
|
+
mkdirSync(dirname(yamlOut), { recursive: true });
|
|
240
|
+
writeFileSync(yamlOut, readFileSync(yamlSrc));
|
|
241
|
+
copiedYamls++;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const result = await Bun.build({
|
|
245
|
+
entrypoints: [join(compSrc, "info.ts")],
|
|
246
|
+
outdir: compOut,
|
|
247
|
+
target: "browser",
|
|
248
|
+
format: "esm",
|
|
249
|
+
naming: "info.client.js",
|
|
250
|
+
// @norskvideo/* is inlined (design-time helpers must ship in the bundle);
|
|
251
|
+
// react is rewritten to a window global by the plugin so no second React
|
|
252
|
+
// is bundled. @react-icons stays external — Studio provides it.
|
|
253
|
+
external: ["@react-icons/all-files/*"],
|
|
254
|
+
plugins: [externalGlobalPlugin],
|
|
255
|
+
});
|
|
256
|
+
if (!result.success) {
|
|
257
|
+
console.error(result.logs);
|
|
258
|
+
throw new Error(\`bun.build failed for \${library}/\${component}/info.ts\`);
|
|
259
|
+
}
|
|
260
|
+
bundledClients++;
|
|
261
|
+
console.log(\` \${library}/\${component}/info.ts -> \${relative(process.cwd(), join(compOut, "info.client.js"))}\`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
console.log(\`summary: \${writtenPackages} package.json, \${copiedYamls} types.yaml, \${bundledClients} info.client.js\`);
|
|
266
|
+
`;
|
|
267
|
+
|
|
268
|
+
function libraryIndexTs(ctx: ShapeContext): string {
|
|
269
|
+
return `// Library entry — Studio calls \`default(system)\` after mounting this directory
|
|
270
|
+
// at its package resolution path inside the studio container.
|
|
271
|
+
//
|
|
272
|
+
// Every subdirectory shipping BOTH an \`info.js\` and a \`runtime.js\` is a
|
|
273
|
+
// component: no registration step, so adding one is "drop a sibling directory".
|
|
274
|
+
//
|
|
275
|
+
// Resolution is a static readdir of __dirname (the COMPILED lib/library/), not
|
|
276
|
+
// a RuntimeSystem + registerAll — registerAll reads the package's lib/ at
|
|
277
|
+
// runtime and bundling bakes in the build host's path, which does not exist in
|
|
278
|
+
// the container.
|
|
279
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
280
|
+
import path from "node:path";
|
|
281
|
+
import type { BaseConfig, NodeInfo } from "@norskvideo/norsk-studio/lib/extension/client-types";
|
|
282
|
+
import { RegistrationConsts } from "@norskvideo/norsk-studio/lib/extension/client-types";
|
|
283
|
+
import type { RuntimeSystem } from "@norskvideo/norsk-studio/lib/extension/runtime-system";
|
|
284
|
+
import type { CreatedMediaNode, ServerComponentDefinition } from "@norskvideo/norsk-studio/lib/extension/runtime-types";
|
|
285
|
+
|
|
286
|
+
const LIBRARY = "${libraryPackage(ctx)}";
|
|
287
|
+
|
|
288
|
+
type InfoModule = {
|
|
289
|
+
default: (consts: typeof RegistrationConsts) => NodeInfo<BaseConfig, object, object, object>;
|
|
290
|
+
};
|
|
291
|
+
type RuntimeModule = {
|
|
292
|
+
default: new () => ServerComponentDefinition<BaseConfig, CreatedMediaNode, object, object, object>;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const register = async (system: RuntimeSystem): Promise<void> => {
|
|
296
|
+
for (const entry of readdirSync(__dirname, { withFileTypes: true })) {
|
|
297
|
+
if (!entry.isDirectory()) continue;
|
|
298
|
+
const dir = path.join(__dirname, entry.name);
|
|
299
|
+
const infoPath = path.join(dir, "info.js");
|
|
300
|
+
const runtimePath = path.join(dir, "runtime.js");
|
|
301
|
+
if (!existsSync(infoPath) || !existsSync(runtimePath)) continue;
|
|
302
|
+
|
|
303
|
+
const infoMod = (await import(infoPath)) as InfoModule;
|
|
304
|
+
const runtimeMod = (await import(runtimePath)) as RuntimeModule;
|
|
305
|
+
const info = infoMod.default(RegistrationConsts);
|
|
306
|
+
if (info.library === undefined) info.library = LIBRARY;
|
|
307
|
+
// The THIRD argument is the ESM browser bundle, never the CommonJS info.js:
|
|
308
|
+
// Studio's workflow view import()s it directly and \`exports is not defined\`
|
|
309
|
+
// is what a CJS file there looks like.
|
|
310
|
+
system.registerComponent(new runtimeMod.default(), info, path.join(dir, "info.client.js"), dir);
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
export default register;
|
|
315
|
+
`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const EXTERNALS_TEST_TS = `// Regression guard: Studio's browser plugin loader (bypass-esbuild.js) does a
|
|
319
|
+
// raw import() of each info.client.js with NO import map, so any bare specifier
|
|
320
|
+
// left in the emitted bundle is a fatal module-resolution crash in the Studio
|
|
321
|
+
// workflow view. copy-yamls must inline @norskvideo/* and rewrite react /
|
|
322
|
+
// react/jsx-runtime to Studio's window globals rather than leave them bare.
|
|
323
|
+
import { describe, expect, test } from "bun:test";
|
|
324
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
325
|
+
import { join } from "node:path";
|
|
326
|
+
|
|
327
|
+
const COMPONENTS_ROOT = join(import.meta.dir, "..", "..");
|
|
328
|
+
const LIB_LIBRARY = join(COMPONENTS_ROOT, "lib", "library");
|
|
329
|
+
|
|
330
|
+
function buildClients(): void {
|
|
331
|
+
const proc = Bun.spawnSync(["bun", "run", "scripts/copy-yamls.ts"], {
|
|
332
|
+
cwd: COMPONENTS_ROOT,
|
|
333
|
+
stdout: "pipe",
|
|
334
|
+
stderr: "pipe",
|
|
335
|
+
});
|
|
336
|
+
if (proc.exitCode !== 0) throw new Error(\`copy-yamls failed:\\n\${proc.stderr.toString()}\`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function clientBundles(): string[] {
|
|
340
|
+
return readdirSync(LIB_LIBRARY, { withFileTypes: true })
|
|
341
|
+
.filter((e) => e.isDirectory())
|
|
342
|
+
.map((e) => join(LIB_LIBRARY, e.name, "info.client.js"))
|
|
343
|
+
.filter((p) => {
|
|
344
|
+
try {
|
|
345
|
+
return statSync(p).isFile();
|
|
346
|
+
} catch {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const BARE_NORSK =
|
|
353
|
+
/(?:import|export)[^;]*?from\\s*["']@norskvideo\\/[^"']*["']|(?:import|require)\\s*\\(\\s*["']@norskvideo\\/[^"']*["']/;
|
|
354
|
+
const BARE_REACT =
|
|
355
|
+
/(?:import|export)[^;]*?from\\s*["']react(?:\\/jsx-runtime)?["']|(?:import|require)\\s*\\(\\s*["']react(?:\\/jsx-runtime)?["']/;
|
|
356
|
+
|
|
357
|
+
describe("emitted info.client.js bundles", () => {
|
|
358
|
+
buildClients();
|
|
359
|
+
const bundles = clientBundles();
|
|
360
|
+
|
|
361
|
+
test("at least one client bundle is emitted", () => {
|
|
362
|
+
expect(bundles.length).toBeGreaterThan(0);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
for (const bundle of bundles) {
|
|
366
|
+
const code = readFileSync(bundle, "utf8");
|
|
367
|
+
test(\`\${bundle} has no bare @norskvideo import\`, () => {
|
|
368
|
+
expect(BARE_NORSK.test(code)).toBe(false);
|
|
369
|
+
});
|
|
370
|
+
test(\`\${bundle} has no bare react / react/jsx-runtime import\`, () => {
|
|
371
|
+
expect(BARE_REACT.test(code)).toBe(false);
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
`;
|
|
376
|
+
|
|
377
|
+
const EXAMPLE_TYPES_YAML = `openapi: 3.0.0
|
|
378
|
+
info:
|
|
379
|
+
title: example
|
|
380
|
+
version: 1.0.0
|
|
381
|
+
|
|
382
|
+
# PLACEHOLDER COMPONENT. Rename the directory, the identifier in info.ts, and
|
|
383
|
+
# this title when the real component lands; delete it outright if the product
|
|
384
|
+
# needs none.
|
|
385
|
+
#
|
|
386
|
+
# codegen.ts generates \`_gen/types.ts\` from this file; copy-yamls ships it
|
|
387
|
+
# verbatim as \`lib/.../types.yaml\`, which runtime.ts reads at load via
|
|
388
|
+
# schemaFromTypes({ config: "Config" }) — so the \`Config\` schema below MUST
|
|
389
|
+
# exist, under exactly that name.
|
|
390
|
+
|
|
391
|
+
paths: {}
|
|
392
|
+
|
|
393
|
+
components:
|
|
394
|
+
schemas:
|
|
395
|
+
# The component's Settings shape. Kept lenient (all-optional) so the
|
|
396
|
+
# id/displayName/__global the composer also stamps into the node config
|
|
397
|
+
# pass through untouched.
|
|
398
|
+
Config:
|
|
399
|
+
type: object
|
|
400
|
+
properties:
|
|
401
|
+
notes:
|
|
402
|
+
type: string
|
|
403
|
+
# How often the heartbeat state is republished. The heartbeat exists
|
|
404
|
+
# only to prove the state/websocket path end to end; drop it with the
|
|
405
|
+
# rest of the placeholder.
|
|
406
|
+
heartbeatMs:
|
|
407
|
+
type: number
|
|
408
|
+
example: 1000
|
|
409
|
+
`;
|
|
410
|
+
|
|
411
|
+
function exampleInfoTs(ctx: ShapeContext): string {
|
|
412
|
+
const hasViews = ctx.features.has("views");
|
|
413
|
+
// Assembled rather than spliced: biome sorts imports, and the two view
|
|
414
|
+
// modules sort AROUND ./runtime (fullscreen < runtime < summary), so a single
|
|
415
|
+
// insertion point cannot produce a lint-clean file.
|
|
416
|
+
const infoImports = [
|
|
417
|
+
'import type Registration from "@norskvideo/norsk-studio/lib/extension/registration";',
|
|
418
|
+
...(hasViews ? [VIEWS_INFO_IMPORTS.fullscreen] : []),
|
|
419
|
+
'import type { ExampleCommand, ExampleEvent, ExampleSettings, ExampleState } from "./runtime";',
|
|
420
|
+
...(hasViews ? [VIEWS_INFO_IMPORTS.summary] : []),
|
|
421
|
+
].join("\n");
|
|
422
|
+
const viewRuntime = hasViews ? VIEWS_INFO_RUNTIME : "";
|
|
423
|
+
return `// PLACEHOLDER COMPONENT — design-time NodeInfo. See runtime.ts.
|
|
424
|
+
//
|
|
425
|
+
// \`subscription: {}\` means it neither accepts nor produces media: a pure
|
|
426
|
+
// side-car that serves routes and publishes state, so it can be dropped into
|
|
427
|
+
// any graph — or none — without perturbing it. That is also why there is no
|
|
428
|
+
// extraValidation: there are no streams to require.
|
|
429
|
+
//
|
|
430
|
+
// This file is compiled TWICE: to CommonJS (info.js, for the Studio server) and
|
|
431
|
+
// to a browser ESM bundle (info.client.js, via copy-yamls). Anything imported
|
|
432
|
+
// here has to survive both — which is why the bundler rewrites react to
|
|
433
|
+
// Studio's window globals rather than bundling a second copy.
|
|
434
|
+
${infoImports}
|
|
435
|
+
|
|
436
|
+
export default function (R: Registration) {
|
|
437
|
+
const { defineComponent } = R;
|
|
438
|
+
|
|
439
|
+
return defineComponent<ExampleSettings, ExampleState, ExampleCommand, ExampleEvent>({
|
|
440
|
+
identifier: "${EXAMPLE_IDENTIFIER}",
|
|
441
|
+
category: "output",
|
|
442
|
+
name: "Example",
|
|
443
|
+
description: "Placeholder component — publishes a heartbeat and serves /status. Replace or delete.",
|
|
444
|
+
subscription: {},
|
|
445
|
+
display: (_desc) => ({}),
|
|
446
|
+
runtime: {
|
|
447
|
+
${viewRuntime} // This component pushes whole states via updates.update(), so the reducer
|
|
448
|
+
// has nothing to fold. A component whose state is a fold over things that
|
|
449
|
+
// happen would raiseEvent() instead and build the state here.
|
|
450
|
+
initialState: () => ({ ticks: 0 }),
|
|
451
|
+
},
|
|
452
|
+
configForm: {
|
|
453
|
+
form: {
|
|
454
|
+
heartbeatMs: {
|
|
455
|
+
help: "How often the heartbeat republishes, in milliseconds",
|
|
456
|
+
hint: { type: "numeric", optional: true },
|
|
457
|
+
},
|
|
458
|
+
notes: { help: "Notes about this component", hint: { type: "text", optional: true } },
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
`;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const EXAMPLE_RUNTIME_TS = `// PLACEHOLDER COMPONENT — the smallest thing that exercises the whole plugin
|
|
467
|
+
// pipeline: it registers, appears in the toolbox, publishes state over
|
|
468
|
+
// /live/<id>/ws, and answers one route at /live/api/<id>/status. It carries no
|
|
469
|
+
// media at all (subscription: {} in info.ts).
|
|
470
|
+
//
|
|
471
|
+
// Replace or delete. When the real component lands, keep two habits from here:
|
|
472
|
+
// the pure logic belongs in a sibling module (unit-testable with no Studio),
|
|
473
|
+
// and the initial state is published from the constructor.
|
|
474
|
+
import path from "node:path";
|
|
475
|
+
import type { Norsk } from "@norskvideo/norsk-sdk";
|
|
476
|
+
import type {
|
|
477
|
+
CreatedMediaNode,
|
|
478
|
+
InstanceRouteInfo,
|
|
479
|
+
OnCreated,
|
|
480
|
+
RuntimeUpdates,
|
|
481
|
+
ServerComponentDefinition,
|
|
482
|
+
ServerComponentSchemas,
|
|
483
|
+
StudioRuntime,
|
|
484
|
+
} from "@norskvideo/norsk-studio/lib/extension/runtime-types";
|
|
485
|
+
import { RelatedMediaNodes, schemaFromTypes } from "@norskvideo/norsk-studio/lib/extension/runtime-types";
|
|
486
|
+
import type { Config } from "./_gen/types";
|
|
487
|
+
|
|
488
|
+
export type ExampleSettings = Config & { id: string; displayName: string };
|
|
489
|
+
export type ExampleState = { ticks: number };
|
|
490
|
+
export type ExampleCommand = { type: "noop" };
|
|
491
|
+
export type ExampleEvent = { type: "noop" };
|
|
492
|
+
|
|
493
|
+
const DEFAULT_HEARTBEAT_MS = 1000;
|
|
494
|
+
|
|
495
|
+
class ExampleNode implements CreatedMediaNode {
|
|
496
|
+
id: string;
|
|
497
|
+
relatedMediaNodes = new RelatedMediaNodes();
|
|
498
|
+
|
|
499
|
+
private ticks = 0;
|
|
500
|
+
private timer: ReturnType<typeof setInterval>;
|
|
501
|
+
private updates: RuntimeUpdates<ExampleState, ExampleCommand, ExampleEvent>;
|
|
502
|
+
|
|
503
|
+
constructor(cfg: ExampleSettings, updates: RuntimeUpdates<ExampleState, ExampleCommand, ExampleEvent>) {
|
|
504
|
+
this.id = cfg.id;
|
|
505
|
+
this.updates = updates;
|
|
506
|
+
// Publish immediately: Studio's /live/<id>/ws REJECTS connections while a
|
|
507
|
+
// component's state is undefined, so an operator screen that connects
|
|
508
|
+
// before the first heartbeat would be refused.
|
|
509
|
+
this.publish();
|
|
510
|
+
this.timer = setInterval(() => {
|
|
511
|
+
this.ticks++;
|
|
512
|
+
this.publish();
|
|
513
|
+
}, cfg.heartbeatMs ?? DEFAULT_HEARTBEAT_MS);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
status(): ExampleState {
|
|
517
|
+
return { ticks: this.ticks };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private publish(): void {
|
|
521
|
+
this.updates.update(this.status());
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async close(): Promise<void> {
|
|
525
|
+
clearInterval(this.timer);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export default class ExampleDefinition
|
|
530
|
+
implements ServerComponentDefinition<ExampleSettings, ExampleNode, ExampleState, ExampleCommand, ExampleEvent>
|
|
531
|
+
{
|
|
532
|
+
async create(
|
|
533
|
+
_norsk: Norsk,
|
|
534
|
+
cfg: ExampleSettings,
|
|
535
|
+
cb: OnCreated<ExampleNode>,
|
|
536
|
+
runtime: StudioRuntime<ExampleState, ExampleCommand, ExampleEvent>,
|
|
537
|
+
): Promise<void> {
|
|
538
|
+
cb(new ExampleNode(cfg, runtime.updates));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async instanceRoutes(): Promise<
|
|
542
|
+
InstanceRouteInfo<ExampleSettings, ExampleNode, ExampleState, ExampleCommand, ExampleEvent>[]
|
|
543
|
+
> {
|
|
544
|
+
return [
|
|
545
|
+
{
|
|
546
|
+
url: "/status",
|
|
547
|
+
method: "GET",
|
|
548
|
+
summary: "Heartbeat tick count",
|
|
549
|
+
category: "Component",
|
|
550
|
+
handler:
|
|
551
|
+
({ node }) =>
|
|
552
|
+
(_req, res) => {
|
|
553
|
+
res.json(node.status());
|
|
554
|
+
},
|
|
555
|
+
},
|
|
556
|
+
];
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async schemas(): Promise<ServerComponentSchemas> {
|
|
560
|
+
// types.yaml sits next to the COMPILED runtime.js, emitted by copy-yamls.
|
|
561
|
+
return schemaFromTypes(path.join(__dirname, "types.yaml"), { config: "Config" });
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
`;
|
|
565
|
+
|
|
566
|
+
const BACKEND_LIB_COMPONENTS_TS = `// The compiled Studio components from the components/ workspace, in the shape
|
|
567
|
+
// buildProductTemplateMaterials expects. Only the root path is ours — the
|
|
568
|
+
// reader (and its .d.ts/.js.map filtering) is @norskvideo/ctl-sdk's.
|
|
569
|
+
import path from "node:path";
|
|
570
|
+
import { loadCompiledComponents as load } from "@norskvideo/ctl-sdk";
|
|
571
|
+
|
|
572
|
+
const COMPONENTS_LIB = path.resolve(import.meta.dir, "../../../components/lib");
|
|
573
|
+
|
|
574
|
+
export const loadCompiledComponents = (root: string = COMPONENTS_LIB) => load(root);
|
|
575
|
+
`;
|
|
576
|
+
|
|
577
|
+
// The composer needs the placeholder's config TYPE, but shared/ must not import
|
|
578
|
+
// components/ — that would couple the ESM shared workspace to the CommonJS one
|
|
579
|
+
// and drag the component's runtime deps into the backend bundle. Mirror the
|
|
580
|
+
// handful of fields instead, exactly as funke does for its own components.
|
|
581
|
+
function exampleConfigTs(_ctx: ShapeContext): string {
|
|
582
|
+
return `// Composer-side mirror of the placeholder component's config
|
|
583
|
+
// (components/src/library/example/types.source.yaml). Deliberately a COPY:
|
|
584
|
+
// shared/ is ESM and components/ is CommonJS, so importing across would drag
|
|
585
|
+
// the component's runtime dependencies into the backend bundle. A drift
|
|
586
|
+
// between the two surfaces as a builder validation failure in the compose
|
|
587
|
+
// tests, because COMPONENT_STUBS mirrors the same shape.
|
|
588
|
+
import type { WorkflowComponent } from "@norskvideo/ctl-sdk/workflow";
|
|
589
|
+
|
|
590
|
+
export type ExampleConfig = {
|
|
591
|
+
id: string;
|
|
592
|
+
displayName: string;
|
|
593
|
+
notes?: string;
|
|
594
|
+
heartbeatMs?: number;
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
export type ExampleArgs = { id: string; displayName: string; heartbeatMs?: number };
|
|
598
|
+
|
|
599
|
+
export function exampleSidecar(args: ExampleArgs): WorkflowComponent<ExampleConfig> {
|
|
600
|
+
return {
|
|
601
|
+
type: "${EXAMPLE_IDENTIFIER}",
|
|
602
|
+
config: { id: args.id, displayName: args.displayName, heartbeatMs: args.heartbeatMs ?? 1000 },
|
|
603
|
+
subscriptions: [],
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
`;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
export function componentsFiles(ctx: ShapeContext): GeneratedFile[] {
|
|
610
|
+
return [
|
|
611
|
+
{ path: "components/package.json", content: packageJson(ctx) },
|
|
612
|
+
{ path: "components/tsconfig.json", content: TSCONFIG },
|
|
613
|
+
{ path: "components/scripts/codegen.ts", content: CODEGEN_TS },
|
|
614
|
+
// The per-library package.json copy-yamls writes names the product scope,
|
|
615
|
+
// which only the generator knows.
|
|
616
|
+
{ path: "components/scripts/copy-yamls.ts", content: COPY_YAMLS_TS.replaceAll("__SCOPE__", ctx.scope) },
|
|
617
|
+
{ path: "components/src/library/index.ts", content: libraryIndexTs(ctx) },
|
|
618
|
+
{ path: "components/src/library/info-client-externals.test.ts", content: EXTERNALS_TEST_TS },
|
|
619
|
+
{ path: "components/src/library/example/types.source.yaml", content: EXAMPLE_TYPES_YAML },
|
|
620
|
+
{ path: "components/src/library/example/info.ts", content: exampleInfoTs(ctx) },
|
|
621
|
+
{ path: "components/src/library/example/runtime.ts", content: EXAMPLE_RUNTIME_TS },
|
|
622
|
+
{ path: "backend/src/lib/components.ts", content: BACKEND_LIB_COMPONENTS_TS },
|
|
623
|
+
{ path: "shared/src/workflow/example-config.ts", content: exampleConfigTs(ctx) },
|
|
624
|
+
];
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export const COMPONENTS_GITIGNORE = `# tsc + copy-yamls output of the components/ workspace (bun run build:components);
|
|
628
|
+
# read by the backend at pack time and staged into the image after a host build.
|
|
629
|
+
components/lib/
|
|
630
|
+
# Component codegen output — regenerated from types.source.yaml on each build.
|
|
631
|
+
components/src/**/_gen/
|
|
632
|
+
`;
|