@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.
@@ -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
+ `;