#!/usr/bin/env node // Message bus — TypeScript DTOs generated from `resources/gen/asyncapi.json`. // // Sits on the same axis as `resources/gen/openapi.json`: the wire spec // (authored by `cargo run --features dev_tools --bin generate-asyncapi`) // is the source of truth, and this script projects it into typed FE // interfaces so `lib/composables/useTopic.ts` and every folder-view // switch statement is compile-time exhaustive over the `rt.event` variants. // // Regenerate: `just asyncapi-ts` (or `npm run gen:message-bus`). // CI is expected to run the same command and fail if the working tree is // dirty afterwards — same discipline `just openapi` follows. // // Design notes: // * `modelType: 'interface'` — plain records, not classes-with-getters. // Matches the FE codebase style (see `lib/api/types.ts`). // * Output goes to `src/lib/generated/message-bus/` — a directory reserved // for auto-generated files. Never hand-edit anything inside. // * Every file gets a `AUTO-GENERATED` banner via a preset so a stray // edit is obvious at review time. // * Modelina auto-detects AsyncAPI 3.0 from the top-level `asyncapi` // field. No explicit input-type flag needed. import { execFile as execFileCb } from 'node:child_process'; import { readFile, readdir, rm, mkdir, writeFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; import { promisify } from 'node:util'; import { TypeScriptFileGenerator } from '@asyncapi/modelina'; const execFile = promisify(execFileCb); // Anchor everything on this script's location so `just asyncapi-ts` from // the repo root and `npm run gen:message-bus` from the frontend both work. const __dirname = dirname(fileURLToPath(import.meta.url)); const frontendRoot = resolve(__dirname, '..'); const repoRoot = resolve(frontendRoot, '..'); const specPath = resolve(repoRoot, 'resources/gen/asyncapi.json'); const outputDir = resolve(frontendRoot, 'src/lib/generated/message-bus'); // Load the spec. Failing here means the wire spec hasn't been generated // yet — hint the operator at the right command. let spec; try { spec = JSON.parse(await readFile(specPath, 'utf8')); } catch (err) { console.error( `gen-message-bus-types: cannot read ${specPath}: ${err.message}\n` + `\nDid you run \`just asyncapi\` first? The Rust generator writes\n` + `resources/gen/asyncapi.json; this script consumes it.` ); process.exit(1); } // Fresh output directory every run — no stale files from a schema that // was removed since last run. CI dirty-tree check catches drift both // ways (missing new + leftover old). await rm(outputDir, { recursive: true, force: true }); await mkdir(outputDir, { recursive: true }); const generator = new TypeScriptFileGenerator({ // Plain interfaces, no class scaffolding. FE consumers use structural // types via `useTopic<...>` and plain object literals. modelType: 'interface', // Use inline types where possible (nested objects) rather than // generating a separate model for every anonymous subschema — keeps // the file count tractable. rawPropertyNames: true, presets: [ { // File-level banner. `class` preset covers both class and // interface output in Modelina's TS generator. class: { self({ content }) { const banner = '// AUTO-GENERATED — do not edit by hand.\n' + '// Regenerate with `just asyncapi-ts` (which runs\n' + '// `node frontend/scripts/gen-message-bus-types.mjs`).\n' + '// Source of truth: resources/gen/asyncapi.json,\n' + '// authored by the Rust `generate-asyncapi` binary.\n'; return `${banner}${content}`; } }, interface: { self({ content }) { const banner = '// AUTO-GENERATED — do not edit by hand.\n' + '// Regenerate with `just asyncapi-ts`.\n'; return `${banner}${content}`; } } } ] }); // Modelina auto-detects AsyncAPI 3.0 from the `asyncapi` root field. // `generateToFiles` writes one file per top-level model and returns the // list of models. Any generation error propagates up as a rejection. const models = await generator.generateToFiles(spec, outputDir, { moduleSystem: 'ESM' }); // Post-process for `verbatimModuleSyntax: true` — Modelina 5.x emits // pre-verbatim shapes (`import X from`, `export default X`) that // modern strict TS rejects. Two mechanical rewrites make the output // pass `svelte-check` under the frontend's tsconfig: // // 1. `import X from './X';` → `import type X from './X';` // 2. `export default X;` → `export type { X as default };` // // Both rewrites are safe because we run Modelina in `modelType: // 'interface'` mode — every top-level export is a type, and every // cross-file default import is a type import. If we ever add // value-emitting output (enums, const objects), tighten this. const files = await readdir(outputDir); let rewritten = 0; for (const f of files) { if (!f.endsWith('.ts')) continue; const path = resolve(outputDir, f); let content = await readFile(path, 'utf8'); const before = content; // Match `import from '';` anywhere in the // file. Modelina puts these at the top; `^...$` with the `m` flag // scopes to whole lines. content = content.replace(/^import (\w+) from '(\.\/[\w_]+)';$/gm, "import type $1 from '$2';"); // Match the trailing `export default ;`. Turn it into the // type-only default-export form the TS spec accepts. content = content.replace(/^export default (\w+);$/gm, 'export type { $1 as default };'); // Modelina-limitation escape hatch: bare `any` → `unknown`. // // JSON Schema has no way to express "any JSON value" in a way // Modelina projects into TypeScript cleanly — a schema of // `{"type": ["object", "array", "string", "number", "boolean", // "null"]}` (every JSON type) or an untyped `{}` still comes out // as `any` in Modelina's default output. The two sites this // affects are: // // * `RtErrorObject.data` — JSON-RPC 2.0 spec: "A Primitive or // Structured value that contains additional information." // * `RtSuccessResponseBody.result` — the generic base; each // specific method has its own typed result schema. // // Both are honestly open on the wire; the client checks a // discriminator (`code` / `method`) before narrowing. // // `unknown` is the correct TS type here — strict supertype of // `any`, forces the consumer to narrow. Every OTHER wart (`Map`, // `additionalProperties`, `AnonymousSchema_N`) MUST be fixed at // the AsyncAPI schema level per project convention; this rewrite // is the sole exception, gated to a Modelina defect. content = content.replace(/\bany\b/g, 'unknown'); if (content !== before) { await writeFile(path, content); rewritten++; } } // Guard against reintroducing anonymous schemas. Modelina falls back // to `AnonymousSchema_N` for every inline / nested schema in the // AsyncAPI spec that doesn't have an explicit component name — the // resulting TS files are unreadable in code review, opaque in imports, // and don't refactor safely. Every real schema should be hoisted to // `#/components/schemas/` in `src/bin/generate-asyncapi.rs` and // referenced via `$ref` instead of embedded inline. // // If this guard trips, look at which inline schema in the AsyncAPI // spec triggered it — usually a nested `params`, `result`, `error`, // or an inline `enum` array — and hoist it to a named schema. const anonymous = files.filter((f) => f.endsWith('.ts') && /^AnonymousSchema_/i.test(f)); if (anonymous.length > 0) { console.error( `gen-message-bus-types: FAIL — Modelina produced ${anonymous.length} ` + `AnonymousSchema_N file(s):` ); for (const f of anonymous) console.error(` - ${f}`); console.error( `\nHoist the corresponding inline schema in\n` + ` src/bin/generate-asyncapi.rs\n` + `to a named entry under \`components.schemas\` and\n` + `reference it via \`ref_schema("")\` instead of\n` + `embedding the object inline. Regenerate with\n` + ` just asyncapi-ts\n` + `and the file count for this run should show 0 AnonymousSchema.\n` ); process.exit(1); } // Run the repo's Prettier over the generated output so the committed // files match the same style as hand-written code — otherwise // `npm run check`'s `prettier --check` step fails. Uses the local // binary so config (.prettierrc, plugins) applies. Run via npx to // stay agnostic of monorepo hoisting. try { await execFile('npx', ['--no-install', 'prettier', '--write', outputDir, '--log-level', 'warn'], { cwd: frontendRoot }); } catch (err) { console.error( `gen-message-bus-types: prettier --write failed: ${err.message}\n` + `The generated files may still be usable but will fail\n` + `\`npm run check\` on the prettier step. Fix prettier setup\n` + `(is @prettier installed in frontend/node_modules?) then\n` + `re-run \`just asyncapi-ts\`.` ); process.exit(1); } console.log( `gen-message-bus-types: wrote ${models.length} model(s) to ${outputDir}` + ` (rewrote ${rewritten} for verbatimModuleSyntax, 0 AnonymousSchema,` + ` prettier-formatted)` );