// The `components` feature: the components/ workspace whose compiled output is // packed into the product-template tar as a Studio custom-node library. // // The build is three steps and the ORDER is load-bearing: // 1. codegen.ts types.source.yaml -> _gen/types.ts (pre-tsc) // 2. tsc src/**.ts -> lib/**.js (CommonJS) // 3. copy-yamls the per-library package.json, each component's types.yaml // next to its compiled runtime.js, and the BROWSER bundle // info.client.js (post-tsc) // // Sources reconciled from the fleet: codegen uses @norskvideo/ctl-oas-to-ts // (ours, and it emits the schema key verbatim rather than PascalCasing it); // copy-yamls is funke's, the only one carrying the external-globals plugin // that keeps react out of the browser bundle. import { type GeneratedFile, PRODUCT_ZOD, type ShapeContext } from "../create-product.ts"; import { VIEWS_INFO_IMPORTS, VIEWS_INFO_RUNTIME } from "./views.ts"; // The identifier the placeholder registers under, referenced from three // generated files (the stub, the composer, the component itself). export const EXAMPLE_IDENTIFIER = "output.example"; export const EXAMPLE_ID = "example"; /** `@/library` — the package name Studio resolves the mounted library by. */ export const libraryPackage = (ctx: ShapeContext): string => `${ctx.scope}/library`; function packageJson(ctx: ShapeContext): string { return `${JSON.stringify( { name: `${ctx.scope}/components`, version: "0.0.1", private: true, // CommonJS: Studio require()s the compiled component at load. type: "commonjs", main: "lib/index.js", scripts: { // clean first: tsc never deletes output, so a removed component would // otherwise still ship from lib/. build: "bun run clean && bun run scripts/codegen.ts && bunx tsc && bun run scripts/copy-yamls.ts", codegen: "bun run scripts/codegen.ts", clean: "rm -rf lib", test: "bun test src/", // _gen/types.ts is gitignored build output, so tsc has nothing to check // against on a clean checkout until codegen has run. pretypecheck: "bun run codegen", typecheck: "bunx tsc --noEmit", }, dependencies: { "@norskvideo/ctl-oas-to-ts": "^0.1.0", react: "^19.0.0", zod: PRODUCT_ZOD, }, devDependencies: { "@norskvideo/norsk-sdk": ctx.pins.mediaLib, "@norskvideo/norsk-studio": ctx.pins.studioLib, "@types/bun": "latest", "@types/express": "^5.0.0", "@types/node": "^22.10.0", "@types/react": "^19.0.10", typescript: "^5.7.2", }, }, null, 2, )}\n`; } // NOT the shared tsconfig.base.json: this workspace is the one CommonJS island // in an otherwise ESM repo, and the module setting below is the reason. const TSCONFIG = `{ "compilerOptions": { "target": "ES2022", // CommonJS so tsc rewrites \`await import()\` to \`require + __importStar\`, // which respects \`__esModule:true\` on CJS modules. node16 would preserve a // native dynamic \`import()\` and Node's CJS-via-ESM interop double-wraps the // exports object, so \`infoMod.default\` becomes the wrapper, not the factory. "module": "commonjs", "moduleResolution": "node", "outDir": "./lib", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "removeComments": true, "declaration": true, "jsx": "react-jsx", "resolveJsonModule": true, "sourceMap": true }, "include": ["src/**/*.ts", "src/**/*.tsx"], "exclude": ["node_modules", "lib", "**/*.test.ts", "**/*.test.tsx"] } `; const CODEGEN_TS = `// Pre-tsc step: regenerate \`_gen/types.ts\` for each component from its // \`types.source.yaml\`. // // The YAML is the single source of truth for the OpenAPI shape; a component's // modules alias the generated types, so if the code builds a shape the schema // does not describe, tsc fails. The same file ships alongside the built // component as \`types.yaml\` (see copy-yamls.ts) for Studio's runtime, which is // why it stays YAML rather than becoming zod like norsk-ctl's own contract. // // Hand-edits to \`_gen/types.ts\` are clobbered — edit the YAML instead. import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { dirname, join, relative } from "node:path"; import { schemasToTypeScript } from "@norskvideo/ctl-oas-to-ts"; const SRC_ROOT = join(import.meta.dir, "..", "src"); function* walk(dir: string): Generator { for (const entry of readdirSync(dir)) { const full = join(dir, entry); if (statSync(full).isDirectory()) { yield* walk(full); } else if (full.endsWith("types.source.yaml")) { yield full; } } } const HEADER = "// AUTO-GENERATED. Edit \`types.source.yaml\` and re-run \`bun run build\`.\\n\\n"; let gen = 0; for (const sourcePath of walk(SRC_ROOT)) { const rel = relative(SRC_ROOT, sourcePath); const outDir = join(dirname(sourcePath), "_gen"); const outPath = join(outDir, "types.ts"); const spec = Bun.YAML.parse(readFileSync(sourcePath, "utf-8")) as { components?: { schemas?: Record }; }; // \`_gen/\` holds nothing but generated files and is gitignored, so on a clean // checkout it does not exist yet. mkdirSync(outDir, { recursive: true }); writeFileSync(outPath, schemasToTypeScript(spec.components?.schemas ?? {}, { header: HEADER })); console.log(\` \${rel} -> \${relative(process.cwd(), outPath)}\`); gen++; } console.log(\`generated \${gen} type file(s)\`); `; const COPY_YAMLS_TS = `// Post-tsc step. For each library under src/ (a subdir shipping an index.ts), // and each component inside it (a subdir shipping an info.ts), emit the runtime // artifacts Studio needs alongside the compiled JS: the per-library // package.json (anchoring Node's CJS resolution at the library root), each // component's types.yaml, and a browser-targeted info.client.js bundle. import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { dirname, join, relative } from "node:path"; import type { BunPlugin } from "bun"; const SRC_ROOT = join(import.meta.dir, "..", "src"); const LIB_ROOT = join(import.meta.dir, "..", "lib"); // Studio's browser plugin loader (@norskvideo/norsk-studio/ui/bypass-esbuild.js) // does a raw import() with NO import map, so a bare specifier left in the bundle // is a fatal module-resolution crash in the workflow view. Studio's own bundler // inlines @norskvideo/* and rewrites react / react/jsx-runtime (plus the webrtc // and hls clients) to window globals injected by the Studio shell — it never // bundles a second copy of React. Replicate that mapping here. const EXTERNAL_GLOBALS: Record = { react: "window.ReactExports", "react/jsx-runtime": "window.ReactJsx", "@norskvideo/webrtc-client": "window.WebRtcClient", "hls.js": "window.HlsJs", }; const EXTERNAL_GLOBAL_NAMESPACE = "external-global"; const externalGlobalPlugin: BunPlugin = { name: "external-global", setup(build) { for (const specifier of Object.keys(EXTERNAL_GLOBALS)) { const filter = new RegExp(\`^\${specifier.replace(/[.*+?^\${}()|[\\]\\\\]/g, "\\\\$&")}$\`); build.onResolve({ filter }, (args) => ({ path: args.path, namespace: EXTERNAL_GLOBAL_NAMESPACE })); } build.onLoad({ filter: /.*/, namespace: EXTERNAL_GLOBAL_NAMESPACE }, (args) => ({ contents: \`module.exports = \${EXTERNAL_GLOBALS[args.path]};\`, loader: "js", })); }, }; function isFile(p: string): boolean { try { return statSync(p).isFile(); } catch { return false; } } function listSubdirs(dir: string): string[] { return readdirSync(dir, { withFileTypes: true }) .filter((e) => e.isDirectory()) .map((e) => e.name); } const libraries = listSubdirs(SRC_ROOT).filter((name) => isFile(join(SRC_ROOT, name, "index.ts"))); if (libraries.length === 0) { console.warn("no libraries found under src/ — nothing to do"); process.exit(0); } let copiedYamls = 0; let bundledClients = 0; let writtenPackages = 0; for (const library of libraries) { const libSrc = join(SRC_ROOT, library); const libOut = join(LIB_ROOT, library); const components = listSubdirs(libSrc).filter((name) => isFile(join(libSrc, name, "info.ts"))); mkdirSync(libOut, { recursive: true }); const pkgPath = join(libOut, "package.json"); writeFileSync( pkgPath, \`\${JSON.stringify( { name: \`__SCOPE__/\${library}\`, version: "0.0.0", private: true, type: "commonjs", main: "index.js", }, null, 2, )}\\n\`, ); writtenPackages++; for (const component of components) { const compSrc = join(libSrc, component); const compOut = join(libOut, component); const yamlSrc = join(compSrc, "types.source.yaml"); if (isFile(yamlSrc)) { const yamlOut = join(compOut, "types.yaml"); mkdirSync(dirname(yamlOut), { recursive: true }); writeFileSync(yamlOut, readFileSync(yamlSrc)); copiedYamls++; } const result = await Bun.build({ entrypoints: [join(compSrc, "info.ts")], outdir: compOut, target: "browser", format: "esm", naming: "info.client.js", // @norskvideo/* is inlined (design-time helpers must ship in the bundle); // react is rewritten to a window global by the plugin so no second React // is bundled. @react-icons stays external — Studio provides it. external: ["@react-icons/all-files/*"], plugins: [externalGlobalPlugin], }); if (!result.success) { console.error(result.logs); throw new Error(\`bun.build failed for \${library}/\${component}/info.ts\`); } bundledClients++; console.log(\` \${library}/\${component}/info.ts -> \${relative(process.cwd(), join(compOut, "info.client.js"))}\`); } } console.log(\`summary: \${writtenPackages} package.json, \${copiedYamls} types.yaml, \${bundledClients} info.client.js\`); `; function libraryIndexTs(ctx: ShapeContext): string { return `// Library entry — Studio calls \`default(system)\` after mounting this directory // at its package resolution path inside the studio container. // // Every subdirectory shipping BOTH an \`info.js\` and a \`runtime.js\` is a // component: no registration step, so adding one is "drop a sibling directory". // // Resolution is a static readdir of __dirname (the COMPILED lib/library/), not // a RuntimeSystem + registerAll — registerAll reads the package's lib/ at // runtime and bundling bakes in the build host's path, which does not exist in // the container. import { existsSync, readdirSync } from "node:fs"; import path from "node:path"; import type { BaseConfig, NodeInfo } from "@norskvideo/norsk-studio/lib/extension/client-types"; import { RegistrationConsts } from "@norskvideo/norsk-studio/lib/extension/client-types"; import type { RuntimeSystem } from "@norskvideo/norsk-studio/lib/extension/runtime-system"; import type { CreatedMediaNode, ServerComponentDefinition } from "@norskvideo/norsk-studio/lib/extension/runtime-types"; const LIBRARY = "${libraryPackage(ctx)}"; type InfoModule = { default: (consts: typeof RegistrationConsts) => NodeInfo; }; type RuntimeModule = { default: new () => ServerComponentDefinition; }; const register = async (system: RuntimeSystem): Promise => { for (const entry of readdirSync(__dirname, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const dir = path.join(__dirname, entry.name); const infoPath = path.join(dir, "info.js"); const runtimePath = path.join(dir, "runtime.js"); if (!existsSync(infoPath) || !existsSync(runtimePath)) continue; const infoMod = (await import(infoPath)) as InfoModule; const runtimeMod = (await import(runtimePath)) as RuntimeModule; const info = infoMod.default(RegistrationConsts); if (info.library === undefined) info.library = LIBRARY; // The THIRD argument is the ESM browser bundle, never the CommonJS info.js: // Studio's workflow view import()s it directly and \`exports is not defined\` // is what a CJS file there looks like. system.registerComponent(new runtimeMod.default(), info, path.join(dir, "info.client.js"), dir); } }; export default register; `; } const EXTERNALS_TEST_TS = `// Regression guard: Studio's browser plugin loader (bypass-esbuild.js) does a // raw import() of each info.client.js with NO import map, so any bare specifier // left in the emitted bundle is a fatal module-resolution crash in the Studio // workflow view. copy-yamls must inline @norskvideo/* and rewrite react / // react/jsx-runtime to Studio's window globals rather than leave them bare. import { describe, expect, test } from "bun:test"; import { readdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; const COMPONENTS_ROOT = join(import.meta.dir, "..", ".."); const LIB_LIBRARY = join(COMPONENTS_ROOT, "lib", "library"); function buildClients(): void { const proc = Bun.spawnSync(["bun", "run", "scripts/copy-yamls.ts"], { cwd: COMPONENTS_ROOT, stdout: "pipe", stderr: "pipe", }); if (proc.exitCode !== 0) throw new Error(\`copy-yamls failed:\\n\${proc.stderr.toString()}\`); } function clientBundles(): string[] { return readdirSync(LIB_LIBRARY, { withFileTypes: true }) .filter((e) => e.isDirectory()) .map((e) => join(LIB_LIBRARY, e.name, "info.client.js")) .filter((p) => { try { return statSync(p).isFile(); } catch { return false; } }); } const BARE_NORSK = /(?:import|export)[^;]*?from\\s*["']@norskvideo\\/[^"']*["']|(?:import|require)\\s*\\(\\s*["']@norskvideo\\/[^"']*["']/; const BARE_REACT = /(?:import|export)[^;]*?from\\s*["']react(?:\\/jsx-runtime)?["']|(?:import|require)\\s*\\(\\s*["']react(?:\\/jsx-runtime)?["']/; describe("emitted info.client.js bundles", () => { buildClients(); const bundles = clientBundles(); test("at least one client bundle is emitted", () => { expect(bundles.length).toBeGreaterThan(0); }); for (const bundle of bundles) { const code = readFileSync(bundle, "utf8"); test(\`\${bundle} has no bare @norskvideo import\`, () => { expect(BARE_NORSK.test(code)).toBe(false); }); test(\`\${bundle} has no bare react / react/jsx-runtime import\`, () => { expect(BARE_REACT.test(code)).toBe(false); }); } }); `; const EXAMPLE_TYPES_YAML = `openapi: 3.0.0 info: title: example version: 1.0.0 # PLACEHOLDER COMPONENT. Rename the directory, the identifier in info.ts, and # this title when the real component lands; delete it outright if the product # needs none. # # codegen.ts generates \`_gen/types.ts\` from this file; copy-yamls ships it # verbatim as \`lib/.../types.yaml\`, which runtime.ts reads at load via # schemaFromTypes({ config: "Config" }) — so the \`Config\` schema below MUST # exist, under exactly that name. paths: {} components: schemas: # The component's Settings shape. Kept lenient (all-optional) so the # id/displayName/__global the composer also stamps into the node config # pass through untouched. Config: type: object properties: notes: type: string # How often the heartbeat state is republished. The heartbeat exists # only to prove the state/websocket path end to end; drop it with the # rest of the placeholder. heartbeatMs: type: number example: 1000 `; function exampleInfoTs(ctx: ShapeContext): string { const hasViews = ctx.features.has("views"); // Assembled rather than spliced: biome sorts imports, and the two view // modules sort AROUND ./runtime (fullscreen < runtime < summary), so a single // insertion point cannot produce a lint-clean file. const infoImports = [ 'import type Registration from "@norskvideo/norsk-studio/lib/extension/registration";', ...(hasViews ? [VIEWS_INFO_IMPORTS.fullscreen, VIEWS_INFO_IMPORTS.inline] : []), 'import type { ExampleCommand, ExampleEvent, ExampleSettings, ExampleState } from "./runtime";', ...(hasViews ? [VIEWS_INFO_IMPORTS.summary] : []), ].join("\n"); const viewRuntime = hasViews ? VIEWS_INFO_RUNTIME : ""; return `// PLACEHOLDER COMPONENT — design-time NodeInfo. See runtime.ts. // // \`subscription: {}\` means it neither accepts nor produces media: a pure // side-car that serves routes and publishes state, so it can be dropped into // any graph — or none — without perturbing it. That is also why there is no // extraValidation: there are no streams to require. // // This file is compiled TWICE: to CommonJS (info.js, for the Studio server) and // to a browser ESM bundle (info.client.js, via copy-yamls). Anything imported // here has to survive both — which is why the bundler rewrites react to // Studio's window globals rather than bundling a second copy. ${infoImports} export default function (R: Registration) { const { defineComponent } = R; return defineComponent({ identifier: "${EXAMPLE_IDENTIFIER}", category: "output", name: "Example", description: "Placeholder component — publishes a heartbeat and serves /status. Replace or delete.", subscription: {}, display: (_desc) => ({}), runtime: { ${viewRuntime} // This component pushes whole states via updates.update(), so the reducer // has nothing to fold. A component whose state is a fold over things that // happen would raiseEvent() instead and build the state here. initialState: () => ({ ticks: 0 }), }, configForm: { form: { heartbeatMs: { help: "How often the heartbeat republishes, in milliseconds", hint: { type: "numeric", optional: true }, }, notes: { help: "Notes about this component", hint: { type: "text", optional: true } }, }, }, }); } `; } const EXAMPLE_RUNTIME_TS = `// PLACEHOLDER COMPONENT — the smallest thing that exercises the whole plugin // pipeline: it registers, appears in the toolbox, publishes state over // /live//ws, and answers one route at /live/api//status. It carries no // media at all (subscription: {} in info.ts). // // Replace or delete. When the real component lands, keep two habits from here: // the pure logic belongs in a sibling module (unit-testable with no Studio), // and the initial state is published from the constructor. import path from "node:path"; import type { Norsk } from "@norskvideo/norsk-sdk"; import type { CreatedMediaNode, InstanceRouteInfo, OnCreated, RuntimeUpdates, ServerComponentDefinition, ServerComponentSchemas, StudioRuntime, } from "@norskvideo/norsk-studio/lib/extension/runtime-types"; import { RelatedMediaNodes, schemaFromTypes } from "@norskvideo/norsk-studio/lib/extension/runtime-types"; import type { Config } from "./_gen/types"; export type ExampleSettings = Config & { id: string; displayName: string }; export type ExampleState = { ticks: number }; export type ExampleCommand = { type: "noop" }; export type ExampleEvent = { type: "noop" }; const DEFAULT_HEARTBEAT_MS = 1000; class ExampleNode implements CreatedMediaNode { id: string; relatedMediaNodes = new RelatedMediaNodes(); private ticks = 0; private timer: ReturnType; private updates: RuntimeUpdates; constructor(cfg: ExampleSettings, updates: RuntimeUpdates) { this.id = cfg.id; this.updates = updates; // Publish immediately: Studio's /live//ws REJECTS connections while a // component's state is undefined, so an operator screen that connects // before the first heartbeat would be refused. this.publish(); this.timer = setInterval(() => { this.ticks++; this.publish(); }, cfg.heartbeatMs ?? DEFAULT_HEARTBEAT_MS); } status(): ExampleState { return { ticks: this.ticks }; } private publish(): void { this.updates.update(this.status()); } async close(): Promise { clearInterval(this.timer); } } export default class ExampleDefinition implements ServerComponentDefinition { async create( _norsk: Norsk, cfg: ExampleSettings, cb: OnCreated, runtime: StudioRuntime, ): Promise { cb(new ExampleNode(cfg, runtime.updates)); } async instanceRoutes(): Promise< InstanceRouteInfo[] > { return [ { url: "/status", method: "GET", summary: "Heartbeat tick count", category: "Component", handler: ({ node }) => (_req, res) => { res.json(node.status()); }, }, ]; } async schemas(): Promise { // types.yaml sits next to the COMPILED runtime.js, emitted by copy-yamls. return schemaFromTypes(path.join(__dirname, "types.yaml"), { config: "Config" }); } } `; const BACKEND_LIB_COMPONENTS_TS = `// The compiled Studio components from the components/ workspace, in the shape // buildProductTemplateMaterials expects. Only the root path is ours — the // reader (and its .d.ts/.js.map filtering) is @norskvideo/ctl-sdk's. import path from "node:path"; import { loadCompiledComponents as load } from "@norskvideo/ctl-sdk"; const COMPONENTS_LIB = path.resolve(import.meta.dir, "../../../components/lib"); export const loadCompiledComponents = (root: string = COMPONENTS_LIB) => load(root); `; // The composer needs the placeholder's config TYPE, but shared/ must not import // components/ — that would couple the ESM shared workspace to the CommonJS one // and drag the component's runtime deps into the backend bundle. Mirror the // handful of fields instead, exactly as funke does for its own components. function exampleConfigTs(_ctx: ShapeContext): string { return `// Composer-side mirror of the placeholder component's config // (components/src/library/example/types.source.yaml). Deliberately a COPY: // shared/ is ESM and components/ is CommonJS, so importing across would drag // the component's runtime dependencies into the backend bundle. A drift // between the two surfaces as a builder validation failure in the compose // tests, because COMPONENT_STUBS mirrors the same shape. import type { WorkflowComponent } from "@norskvideo/ctl-sdk/workflow"; export type ExampleConfig = { id: string; displayName: string; notes?: string; heartbeatMs?: number; }; export type ExampleArgs = { id: string; displayName: string; heartbeatMs?: number }; export function exampleSidecar(args: ExampleArgs): WorkflowComponent { return { type: "${EXAMPLE_IDENTIFIER}", config: { id: args.id, displayName: args.displayName, heartbeatMs: args.heartbeatMs ?? 1000 }, subscriptions: [], }; } `; } export function componentsFiles(ctx: ShapeContext): GeneratedFile[] { return [ { path: "components/package.json", content: packageJson(ctx) }, { path: "components/tsconfig.json", content: TSCONFIG }, { path: "components/scripts/codegen.ts", content: CODEGEN_TS }, // The per-library package.json copy-yamls writes names the product scope, // which only the generator knows. { path: "components/scripts/copy-yamls.ts", content: COPY_YAMLS_TS.replaceAll("__SCOPE__", ctx.scope) }, { path: "components/src/library/index.ts", content: libraryIndexTs(ctx) }, { path: "components/src/library/info-client-externals.test.ts", content: EXTERNALS_TEST_TS }, { path: "components/src/library/example/types.source.yaml", content: EXAMPLE_TYPES_YAML }, { path: "components/src/library/example/info.ts", content: exampleInfoTs(ctx) }, { path: "components/src/library/example/runtime.ts", content: EXAMPLE_RUNTIME_TS }, { path: "backend/src/lib/components.ts", content: BACKEND_LIB_COMPONENTS_TS }, { path: "shared/src/workflow/example-config.ts", content: exampleConfigTs(ctx) }, ]; } export const COMPONENTS_GITIGNORE = `# tsc + copy-yamls output of the components/ workspace (bun run build:components); # read by the backend at pack time and staged into the image after a host build. components/lib/ # Component codegen output — regenerated from types.source.yaml on each build. components/src/**/_gen/ `;