feat(asyncapi): generate ts types according asyncapi

This commit is contained in:
Edouard Vanbelle
2026-09-10 21:33:24 +02:00
parent c4b859c37f
commit 1d280c161c
33 changed files with 4944 additions and 116 deletions
+4110 -3
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -17,9 +17,11 @@
"format": "prettier --write .",
"test:unit": "LANG=C vitest run",
"test:unit:watch": "LANG=C vitest",
"test:unit:coverage": "rm -rf ../tests/e2e/.nyc_output_unit && LANG=C COVERAGE=1 vitest run"
"test:unit:coverage": "rm -rf ../tests/e2e/.nyc_output_unit && LANG=C COVERAGE=1 vitest run",
"gen:realtime": "node scripts/gen-realtime-types.mjs"
},
"devDependencies": {
"@asyncapi/modelina": "^5.5.0",
"@eslint/js": "^10.0.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.66.0",
+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/env node
// Realtime 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:realtime`).
// 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/realtime/` — 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:realtime` 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/realtime');
// 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-realtime-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-realtime-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 <Ident> from '<relative-path>';` 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 <Ident>;`. 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/<Name>` 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-realtime-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("<Name>")\` 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-realtime-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-realtime-types: wrote ${models.length} model(s) to ${outputDir}` +
` (rewrote ${rewritten} for verbatimModuleSyntax, 0 AnonymousSchema,` +
` prettier-formatted)`
);
@@ -0,0 +1,9 @@
// AUTO-GENERATED — do not edit by hand.
// Regenerate with `just asyncapi-ts`.
interface FileCreatedData {
actor: string;
file_id: string;
name: string;
parent_id: string;
}
export type { FileCreatedData as default };
@@ -0,0 +1,8 @@
// AUTO-GENERATED — do not edit by hand.
// Regenerate with `just asyncapi-ts`.
interface FileDeletedData {
actor: string;
file_id: string;
parent_id: string;
}
export type { FileDeletedData as default };
@@ -0,0 +1,10 @@
// AUTO-GENERATED — do not edit by hand.
// Regenerate with `just asyncapi-ts`.
interface FileMovedData {
actor: string;
file_id: string;
from: string;
name: string;
to: string;
}
export type { FileMovedData as default };
@@ -0,0 +1,10 @@
// AUTO-GENERATED — do not edit by hand.
// Regenerate with `just asyncapi-ts`.
interface FileRenamedData {
actor: string;
file_id: string;
new_name: string;
old_name: string;
parent_id: string;
}
export type { FileRenamedData as default };
@@ -0,0 +1,4 @@
import type RtSuccessResponseBody from './RtSuccessResponseBody';
import type RtErrorResponseBody from './RtErrorResponseBody';
type Folder = RtSuccessResponseBody | RtErrorResponseBody;
export type { Folder as default };
@@ -0,0 +1,9 @@
// AUTO-GENERATED — do not edit by hand.
// Regenerate with `just asyncapi-ts`.
interface FolderCreatedData {
actor: string;
folder_id: string;
name: string;
parent_id: string;
}
export type { FolderCreatedData as default };
@@ -0,0 +1,8 @@
// AUTO-GENERATED — do not edit by hand.
// Regenerate with `just asyncapi-ts`.
interface FolderDeletedData {
actor: string;
folder_id: string;
parent_id: string;
}
export type { FolderDeletedData as default };
@@ -0,0 +1,10 @@
// AUTO-GENERATED — do not edit by hand.
// Regenerate with `just asyncapi-ts`.
interface FolderMovedData {
actor: string;
folder_id: string;
from: string;
name: string;
to: string;
}
export type { FolderMovedData as default };
@@ -0,0 +1,10 @@
// AUTO-GENERATED — do not edit by hand.
// Regenerate with `just asyncapi-ts`.
interface FolderRenamedData {
actor: string;
folder_id: string;
new_name: string;
old_name: string;
parent_id: string;
}
export type { FolderRenamedData as default };
@@ -0,0 +1,14 @@
enum RtErrorCode {
MINUS_32001 = -32001,
MINUS_32002 = -32002,
MINUS_32003 = -32003,
MINUS_32004 = -32004,
MINUS_32005 = -32005,
MINUS_32006 = -32006,
MINUS_32007 = -32007,
MINUS_32603 = -32603,
MINUS_32600 = -32600,
MINUS_32601 = -32601,
MINUS_32602 = -32602
}
export type { RtErrorCode as default };
@@ -0,0 +1,14 @@
enum RtErrorMessage {
NO_READ = 'no_read',
NO_SHARE = 'no_share',
NO_COMMENT = 'no_comment',
TOPIC_FORBIDDEN = 'topic_forbidden',
SUB_LIMIT = 'sub_limit',
RATE_LIMITED = 'rate_limited',
NO_EDIT = 'no_edit',
INTERNAL_ERROR = 'internal_error',
INVALID_REQUEST = 'invalid_request',
METHOD_NOT_FOUND = 'method_not_found',
INVALID_PARAMS = 'invalid_params'
}
export type { RtErrorMessage as default };
@@ -0,0 +1,10 @@
import type RtErrorCode from './RtErrorCode';
import type RtErrorMessage from './RtErrorMessage';
// AUTO-GENERATED — do not edit by hand.
// Regenerate with `just asyncapi-ts`.
interface RtErrorObject {
code: RtErrorCode;
data?: unknown;
message: RtErrorMessage;
}
export type { RtErrorObject as default };
@@ -0,0 +1,9 @@
import type RtErrorObject from './RtErrorObject';
// AUTO-GENERATED — do not edit by hand.
// Regenerate with `just asyncapi-ts`.
interface RtErrorResponseBody {
error: RtErrorObject;
id: string | null | number | null | null;
jsonrpc: '2.0';
}
export type { RtErrorResponseBody as default };
@@ -0,0 +1,11 @@
enum RtEventKind {
FILE_CREATED = 'file_created',
FILE_RENAMED = 'file_renamed',
FILE_MOVED = 'file_moved',
FILE_DELETED = 'file_deleted',
FOLDER_CREATED = 'folder_created',
FOLDER_RENAMED = 'folder_renamed',
FOLDER_MOVED = 'folder_moved',
FOLDER_DELETED = 'folder_deleted'
}
export type { RtEventKind as default };
@@ -0,0 +1,25 @@
import type FileCreatedData from './FileCreatedData';
import type FileRenamedData from './FileRenamedData';
import type FileMovedData from './FileMovedData';
import type FileDeletedData from './FileDeletedData';
import type FolderCreatedData from './FolderCreatedData';
import type FolderRenamedData from './FolderRenamedData';
import type FolderMovedData from './FolderMovedData';
import type FolderDeletedData from './FolderDeletedData';
import type RtEventKind from './RtEventKind';
// AUTO-GENERATED — do not edit by hand.
// Regenerate with `just asyncapi-ts`.
interface RtEventParams {
data:
| FileCreatedData
| FileRenamedData
| FileMovedData
| FileDeletedData
| FolderCreatedData
| FolderRenamedData
| FolderMovedData
| FolderDeletedData;
event: RtEventKind;
topic: string;
}
export type { RtEventParams as default };
@@ -0,0 +1,9 @@
import type RtEventParams from './RtEventParams';
// AUTO-GENERATED — do not edit by hand.
// Regenerate with `just asyncapi-ts`.
interface RtFolderEventBody {
jsonrpc: '2.0';
method: 'rt.event';
params: RtEventParams;
}
export type { RtFolderEventBody as default };
@@ -0,0 +1,8 @@
// AUTO-GENERATED — do not edit by hand.
// Regenerate with `just asyncapi-ts`.
interface RtPingRequestBody {
id: string | null | number | null | null;
jsonrpc: '2.0';
method: 'rt.ping';
}
export type { RtPingRequestBody as default };
@@ -0,0 +1,9 @@
import type RtPongResult from './RtPongResult';
// AUTO-GENERATED — do not edit by hand.
// Regenerate with `just asyncapi-ts`.
interface RtPongResponseBody {
id: string | null | number | null | null;
jsonrpc: '2.0';
result: RtPongResult;
}
export type { RtPongResponseBody as default };
@@ -0,0 +1,6 @@
// AUTO-GENERATED — do not edit by hand.
// Regenerate with `just asyncapi-ts`.
interface RtPongResult {
pong: boolean;
}
export type { RtPongResult as default };
@@ -0,0 +1,9 @@
import type RtRevokedParams from './RtRevokedParams';
// AUTO-GENERATED — do not edit by hand.
// Regenerate with `just asyncapi-ts`.
interface RtRevokedBody {
jsonrpc: '2.0';
method: 'rt.revoked';
params: RtRevokedParams;
}
export type { RtRevokedBody as default };
@@ -0,0 +1,8 @@
import type RtRevokedReason from './RtRevokedReason';
// AUTO-GENERATED — do not edit by hand.
// Regenerate with `just asyncapi-ts`.
interface RtRevokedParams {
reason: RtRevokedReason;
topic: string;
}
export type { RtRevokedParams as default };
@@ -0,0 +1,7 @@
enum RtRevokedReason {
GRANT_REVOKED = 'grant_revoked',
RESOURCE_DELETED = 'resource_deleted',
GROUP_MEMBERSHIP_LOST = 'group_membership_lost',
ADMIN_KICK = 'admin_kick'
}
export type { RtRevokedReason as default };
@@ -0,0 +1,6 @@
// AUTO-GENERATED — do not edit by hand.
// Regenerate with `just asyncapi-ts`.
interface RtSubscribeParams {
topic: string;
}
export type { RtSubscribeParams as default };
@@ -0,0 +1,10 @@
import type RtSubscribeParams from './RtSubscribeParams';
// AUTO-GENERATED — do not edit by hand.
// Regenerate with `just asyncapi-ts`.
interface RtSubscribeRequestBody {
id: string | null | number | null | null;
jsonrpc: '2.0';
method: 'rt.subscribe';
params?: RtSubscribeParams;
}
export type { RtSubscribeRequestBody as default };
@@ -0,0 +1,8 @@
// AUTO-GENERATED — do not edit by hand.
// Regenerate with `just asyncapi-ts`.
interface RtSuccessResponseBody {
id: string | null | number | null | null;
jsonrpc: '2.0';
result: unknown;
}
export type { RtSuccessResponseBody as default };
@@ -0,0 +1,6 @@
// AUTO-GENERATED — do not edit by hand.
// Regenerate with `just asyncapi-ts`.
interface RtUnsubscribeParams {
topic: string;
}
export type { RtUnsubscribeParams as default };
@@ -0,0 +1,10 @@
import type RtUnsubscribeParams from './RtUnsubscribeParams';
// AUTO-GENERATED — do not edit by hand.
// Regenerate with `just asyncapi-ts`.
interface RtUnsubscribeRequestBody {
id: string | null | number | null | null;
jsonrpc: '2.0';
method: 'rt.unsubscribe';
params?: RtUnsubscribeParams;
}
export type { RtUnsubscribeRequestBody as default };