@chanx-js/codegen 0.1.0
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/LICENSE +21 -0
- package/README.md +17 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +44 -0
- package/dist/index.d.mts +201 -0
- package/dist/index.mjs +2 -0
- package/dist/loader-DtCKhsCU.mjs +603 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Huy Nguyen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# @chanx-js/codegen
|
|
2
|
+
|
|
3
|
+
Generate typed channel descriptors for the [`@chanx-js/client`](https://www.npmjs.com/package/@chanx-js/client)
|
|
4
|
+
WebSocket client from a [chanx](https://github.com/huynguyengl99/chanx) AsyncAPI 3 schema.
|
|
5
|
+
|
|
6
|
+
**[Documentation](https://huynguyengl99.github.io/chanx-js/guide/codegen)**
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npx @chanx-js/codegen http://localhost:8000/asyncapi.json -o src/generated
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
- TypeScript output by default, or JavaScript with `.d.ts` via `--emit js`.
|
|
13
|
+
- Optional zod validators with `--validation zod`.
|
|
14
|
+
- Reuse types you already have with `--reuse-from`, and check they still match the
|
|
15
|
+
schema with `--reuse-strict`.
|
|
16
|
+
|
|
17
|
+
MIT licensed.
|
package/dist/cli.d.mts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { n as generate, t as loadSchema } from "./loader-DtCKhsCU.mjs";
|
|
3
|
+
import { cac } from "cac";
|
|
4
|
+
//#region src/cli.ts
|
|
5
|
+
const cli = cac("chanx-codegen");
|
|
6
|
+
cli.command("[schema]", "Generate a typed client from a chanx AsyncAPI 3 schema").option("-s, --schema <input>", "URL or path to the AsyncAPI document").option("-o, --out <dir>", "Output directory", { default: "src/generated" }).option("--validation <mode>", "none | zod", { default: "none" }).option("--emit <target>", "ts | js (js writes .js alongside .d.ts)", { default: "ts" }).option("--reuse-from <glob>", "Glob of files declaring types to reuse (repeatable)").option("--reuse-strict", "Emit compile-time assertions that reused types still match").option("--ambient", "Skip reused types without importing them (global script output)").option("--alias <mapping>", "Import path rewrite, e.g. src/=@/ (repeatable)").option("--no-format", "Skip prettier formatting").action(async (positional, flags) => {
|
|
7
|
+
const input = flags.schema ?? positional;
|
|
8
|
+
if (!input) {
|
|
9
|
+
console.error("Pass a schema: chanx-codegen <url|path> -o src/generated");
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
const alias = Object.fromEntries(toArray(flags.alias).map((entry) => {
|
|
13
|
+
const index = entry.indexOf("=");
|
|
14
|
+
if (index === -1) throw new Error(`--alias expects prefix=replacement, got "${entry}"`);
|
|
15
|
+
return [entry.slice(0, index), entry.slice(index + 1)];
|
|
16
|
+
}));
|
|
17
|
+
console.log(`Loading ${input}`);
|
|
18
|
+
const document = await loadSchema(input);
|
|
19
|
+
console.log(` ${document.info?.title ?? "untitled"} ${document.info?.version ?? ""}`);
|
|
20
|
+
const result = await generate(document, {
|
|
21
|
+
outDir: flags.out,
|
|
22
|
+
validation: flags.validation,
|
|
23
|
+
emit: flags.emit,
|
|
24
|
+
reuseFrom: toArray(flags.reuseFrom),
|
|
25
|
+
reuseStrict: Boolean(flags.reuseStrict),
|
|
26
|
+
ambient: Boolean(flags.ambient),
|
|
27
|
+
alias,
|
|
28
|
+
format: flags.format !== false
|
|
29
|
+
});
|
|
30
|
+
console.log(` ${result.channels} channels, ${result.topics} topics, ${result.generatedTypes} types`);
|
|
31
|
+
if (result.reusedTypes.length) {
|
|
32
|
+
console.log(` reused ${result.reusedTypes.length}: ${result.reusedTypes.join(", ")}`);
|
|
33
|
+
if (!flags.reuseStrict) console.log(" (pass --reuse-strict to check the reused shapes still match)");
|
|
34
|
+
}
|
|
35
|
+
for (const file of result.files) console.log(` wrote ${file}`);
|
|
36
|
+
});
|
|
37
|
+
function toArray(value) {
|
|
38
|
+
if (value === void 0) return [];
|
|
39
|
+
return Array.isArray(value) ? value : [value];
|
|
40
|
+
}
|
|
41
|
+
cli.help();
|
|
42
|
+
cli.parse();
|
|
43
|
+
//#endregion
|
|
44
|
+
export {};
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
//#region src/schema.d.ts
|
|
2
|
+
/** The slice of AsyncAPI 3 that chanx emits. Deliberately permissive. */
|
|
3
|
+
interface JsonSchema {
|
|
4
|
+
$ref?: string;
|
|
5
|
+
title?: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
type?: string | string[];
|
|
8
|
+
const?: unknown;
|
|
9
|
+
enum?: unknown[];
|
|
10
|
+
default?: unknown;
|
|
11
|
+
format?: string;
|
|
12
|
+
nullable?: boolean;
|
|
13
|
+
properties?: Record<string, JsonSchema>;
|
|
14
|
+
required?: string[];
|
|
15
|
+
items?: JsonSchema;
|
|
16
|
+
additionalProperties?: boolean | JsonSchema;
|
|
17
|
+
anyOf?: JsonSchema[];
|
|
18
|
+
oneOf?: JsonSchema[];
|
|
19
|
+
allOf?: JsonSchema[];
|
|
20
|
+
discriminator?: string | {
|
|
21
|
+
propertyName?: string;
|
|
22
|
+
};
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
interface TopicExtension {
|
|
26
|
+
name?: string;
|
|
27
|
+
pattern: string;
|
|
28
|
+
parameters?: string[];
|
|
29
|
+
}
|
|
30
|
+
interface ChannelObject {
|
|
31
|
+
address?: string;
|
|
32
|
+
title?: string;
|
|
33
|
+
description?: string;
|
|
34
|
+
messages?: Record<string, {
|
|
35
|
+
$ref?: string;
|
|
36
|
+
}>;
|
|
37
|
+
parameters?: Record<string, unknown>;
|
|
38
|
+
'x-topic'?: TopicExtension;
|
|
39
|
+
}
|
|
40
|
+
interface OperationObject {
|
|
41
|
+
action?: 'send' | 'receive';
|
|
42
|
+
channel?: {
|
|
43
|
+
$ref?: string;
|
|
44
|
+
};
|
|
45
|
+
messages?: Array<{
|
|
46
|
+
$ref?: string;
|
|
47
|
+
}>;
|
|
48
|
+
reply?: {
|
|
49
|
+
messages?: Array<{
|
|
50
|
+
$ref?: string;
|
|
51
|
+
}>;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
interface AsyncAPIDocument {
|
|
55
|
+
asyncapi?: string;
|
|
56
|
+
info?: {
|
|
57
|
+
title?: string;
|
|
58
|
+
version?: string;
|
|
59
|
+
description?: string;
|
|
60
|
+
};
|
|
61
|
+
channels?: Record<string, ChannelObject>;
|
|
62
|
+
operations?: Record<string, OperationObject>;
|
|
63
|
+
components?: {
|
|
64
|
+
schemas?: Record<string, JsonSchema>;
|
|
65
|
+
messages?: Record<string, {
|
|
66
|
+
payload?: JsonSchema;
|
|
67
|
+
}>;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/analyze.d.ts
|
|
72
|
+
interface ChannelInfo {
|
|
73
|
+
/** Key in `channels`. */
|
|
74
|
+
key: string;
|
|
75
|
+
/** chanx's own name for the channel, used as the descriptor's `name`. */
|
|
76
|
+
name: string;
|
|
77
|
+
address: string;
|
|
78
|
+
description?: string;
|
|
79
|
+
toServer: string[];
|
|
80
|
+
toClient: string[];
|
|
81
|
+
topic?: TopicExtension;
|
|
82
|
+
/** Sends `ping` and receives `pong`, so the runtime may heartbeat it. */
|
|
83
|
+
heartbeat: boolean;
|
|
84
|
+
}
|
|
85
|
+
interface ConnectionInfo extends ChannelInfo {
|
|
86
|
+
topics: ChannelInfo[];
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Split channels into connections and the topics riding on them.
|
|
90
|
+
*
|
|
91
|
+
* A topic channel shares its connection's address, which is what groups the two;
|
|
92
|
+
* chanx's own Python generator uses the same rule. A topic channel with no plain
|
|
93
|
+
* channel at its address owns its connection, so it is emitted as both.
|
|
94
|
+
*/
|
|
95
|
+
export declare function analyze(document: AsyncAPIDocument): ConnectionInfo[];
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region src/emit-channels.d.ts
|
|
98
|
+
interface ChannelEmitOptions {
|
|
99
|
+
withZod: boolean;
|
|
100
|
+
schemasModule: string;
|
|
101
|
+
zodModule: string;
|
|
102
|
+
/** `ts` emits one typed module; `js` splits runtime and declarations. */
|
|
103
|
+
target: 'ts' | 'js';
|
|
104
|
+
}
|
|
105
|
+
/** The descriptor values. Valid TypeScript or JavaScript depending on `target`. */
|
|
106
|
+
export declare function emitChannels(connections: ConnectionInfo[], messageNames: string[], options: ChannelEmitOptions): string;
|
|
107
|
+
/**
|
|
108
|
+
* The `.d.ts` companion to a JavaScript `channels.js`.
|
|
109
|
+
*
|
|
110
|
+
* Declares the same descriptors with their full generic arguments, so a plain-JS project
|
|
111
|
+
* still gets narrowing, address-param checking and topic typing from its editor.
|
|
112
|
+
*/
|
|
113
|
+
export declare function emitChannelsDeclaration(connections: ConnectionInfo[], messageNames: string[], options: ChannelEmitOptions): string;
|
|
114
|
+
//#endregion
|
|
115
|
+
//#region src/emit-types.d.ts
|
|
116
|
+
export declare function tsType(schema: JsonSchema | undefined, indent?: string): string;
|
|
117
|
+
/** Emit a named declaration, preferring `interface` for plain object shapes. */
|
|
118
|
+
export declare function declareType(name: string, schema: JsonSchema): string;
|
|
119
|
+
export declare function emitSchemas(schemas: Record<string, JsonSchema>, skip: ReadonlySet<string>): string;
|
|
120
|
+
//#endregion
|
|
121
|
+
//#region src/emit-zod.d.ts
|
|
122
|
+
export declare function emitZod(schemas: Record<string, JsonSchema>, connections: ConnectionInfo[], skip: ReadonlySet<string>): string;
|
|
123
|
+
/**
|
|
124
|
+
* The `.d.ts` companion to a JavaScript `schemas.zod.js`.
|
|
125
|
+
*
|
|
126
|
+
* Each validator is declared as `z.ZodType<T>`, which is what `parse` needs to return
|
|
127
|
+
* the right type; reproducing the precise zod class by hand buys nothing.
|
|
128
|
+
*/
|
|
129
|
+
export declare function emitZodDeclaration(schemas: Record<string, JsonSchema>, connections: ConnectionInfo[], skip: ReadonlySet<string>, schemasModule: string, channelsModule: string): string;
|
|
130
|
+
//#endregion
|
|
131
|
+
//#region src/reuse.d.ts
|
|
132
|
+
interface ReuseOptions {
|
|
133
|
+
/** Globs scanned for types that already exist, e.g. `src/types/backend/**\/*.ts`. */
|
|
134
|
+
reuseFrom?: string[];
|
|
135
|
+
/** Explicit overrides: type name to module specifier. Wins over the scan. */
|
|
136
|
+
reuseMap?: Record<string, string>;
|
|
137
|
+
/**
|
|
138
|
+
* Treat found types as ambient globals and emit no imports, matching a setup where
|
|
139
|
+
* generated files are non-module scripts merged into the global scope.
|
|
140
|
+
*/
|
|
141
|
+
ambient?: boolean;
|
|
142
|
+
/** Path prefix rewrites applied to import specifiers, e.g. `{ "src/": "@/" }`. */
|
|
143
|
+
alias?: Record<string, string>;
|
|
144
|
+
}
|
|
145
|
+
interface ReuseResolution {
|
|
146
|
+
/** Type names that exist elsewhere and must not be generated. */
|
|
147
|
+
skip: Set<string>;
|
|
148
|
+
/** Module specifier to the names imported from it. Empty in ambient mode. */
|
|
149
|
+
imports: Map<string, Set<string>>;
|
|
150
|
+
/** Where each reused name was found, for the report and the strict check. */
|
|
151
|
+
sources: Map<string, string>;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Find types the project already declares so they are referenced rather than regenerated.
|
|
155
|
+
*
|
|
156
|
+
* Uses the TypeScript AST rather than a line regex: `export type X`, `interface X` and
|
|
157
|
+
* indented declarations are all real cases a regex over `^type\s+(\w+)\s*=` misses.
|
|
158
|
+
* `ts-morph` is loaded only here, so a run without `reuseFrom` never pays for it.
|
|
159
|
+
*/
|
|
160
|
+
export declare function resolveReuse(wanted: ReadonlySet<string>, outDir: string, options: ReuseOptions): Promise<ReuseResolution>;
|
|
161
|
+
export declare function renderImports(imports: Map<string, Set<string>>): string;
|
|
162
|
+
//#endregion
|
|
163
|
+
//#region src/generate.d.ts
|
|
164
|
+
interface GenerateOptions extends ReuseOptions {
|
|
165
|
+
outDir: string;
|
|
166
|
+
/** Also emit zod schemas and wire them into the descriptors' validators. */
|
|
167
|
+
validation?: 'none' | 'zod';
|
|
168
|
+
/** Emit a file asserting reused types still match the schema. */
|
|
169
|
+
reuseStrict?: boolean;
|
|
170
|
+
/**
|
|
171
|
+
* `ts` writes one typed module per concern. `js` splits each into a runtime `.js` and
|
|
172
|
+
* a `.d.ts`, so a plain JavaScript project can import the descriptors and still get
|
|
173
|
+
* narrowing from its editor.
|
|
174
|
+
*/
|
|
175
|
+
emit?: 'ts' | 'js';
|
|
176
|
+
format?: boolean;
|
|
177
|
+
}
|
|
178
|
+
interface GenerateResult {
|
|
179
|
+
files: string[];
|
|
180
|
+
generatedTypes: number;
|
|
181
|
+
reusedTypes: string[];
|
|
182
|
+
channels: number;
|
|
183
|
+
topics: number;
|
|
184
|
+
}
|
|
185
|
+
export declare function generate(document: AsyncAPIDocument, options: GenerateOptions): Promise<GenerateResult>;
|
|
186
|
+
//#endregion
|
|
187
|
+
//#region src/loader.d.ts
|
|
188
|
+
/** Load an AsyncAPI document from a URL or a path. JSON and YAML both parse. */
|
|
189
|
+
export declare function loadSchema(input: string): Promise<AsyncAPIDocument>;
|
|
190
|
+
//#endregion
|
|
191
|
+
//#region src/naming.d.ts
|
|
192
|
+
/**
|
|
193
|
+
* PascalCase that preserves casing inside each part.
|
|
194
|
+
*
|
|
195
|
+
* `ag_ui_run` becomes `AgUiRun`, and `PingMessage` stays `PingMessage`. A
|
|
196
|
+
* `title()`-style pass would flatten it to `Pingmessage`.
|
|
197
|
+
*/
|
|
198
|
+
export declare function pascalCase(value: string): string;
|
|
199
|
+
export declare function camelCase(value: string): string;
|
|
200
|
+
//#endregion
|
|
201
|
+
export type { AsyncAPIDocument, ChannelEmitOptions, ChannelInfo, ChannelObject, ConnectionInfo, GenerateOptions, GenerateResult, JsonSchema, OperationObject, ReuseOptions, ReuseResolution };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { a as emitZod, c as emitSchemas, d as emitChannelsDeclaration, f as camelCase, i as resolveReuse, l as tsType, m as analyze, n as generate, o as emitZodDeclaration, p as pascalCase, r as renderImports, s as declareType, t as loadSchema, u as emitChannels } from "./loader-DtCKhsCU.mjs";
|
|
2
|
+
export { analyze, camelCase, declareType, emitChannels, emitChannelsDeclaration, emitSchemas, emitZod, emitZodDeclaration, generate, loadSchema, pascalCase, renderImports, resolveReuse, tsType };
|
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import { parse } from "yaml";
|
|
4
|
+
//#region src/schema.ts
|
|
5
|
+
function refName(ref) {
|
|
6
|
+
return ref.slice(ref.lastIndexOf("/") + 1);
|
|
7
|
+
}
|
|
8
|
+
/** Resolve `#/components/messages/x` to the schema name behind its payload. */
|
|
9
|
+
function messageSchemaName(document, ref) {
|
|
10
|
+
if (!ref) return null;
|
|
11
|
+
const payloadRef = (document.components?.messages?.[refName(ref)])?.payload?.$ref;
|
|
12
|
+
return payloadRef ? refName(payloadRef) : null;
|
|
13
|
+
}
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region src/analyze.ts
|
|
16
|
+
function directions(document) {
|
|
17
|
+
const result = /* @__PURE__ */ new Map();
|
|
18
|
+
for (const key of Object.keys(document.channels ?? {})) result.set(key, {
|
|
19
|
+
toServer: /* @__PURE__ */ new Set(),
|
|
20
|
+
toClient: /* @__PURE__ */ new Set()
|
|
21
|
+
});
|
|
22
|
+
for (const operation of Object.values(document.operations ?? {})) {
|
|
23
|
+
const channelKey = refName(operation.channel?.$ref ?? "");
|
|
24
|
+
const bucket = result.get(channelKey);
|
|
25
|
+
if (!bucket) continue;
|
|
26
|
+
const target = operation.action === "send" ? bucket.toClient : bucket.toServer;
|
|
27
|
+
for (const message of operation.messages ?? []) {
|
|
28
|
+
const name = messageSchemaName(document, message.$ref);
|
|
29
|
+
if (name) target.add(name);
|
|
30
|
+
}
|
|
31
|
+
for (const message of operation.reply?.messages ?? []) {
|
|
32
|
+
const name = messageSchemaName(document, message.$ref);
|
|
33
|
+
if (name) bucket.toClient.add(name);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
/** The `action` constant a message schema pins, if it pins one. */
|
|
39
|
+
function actionOf$1(document, schemaName) {
|
|
40
|
+
return document.components?.schemas?.[schemaName]?.properties?.action?.const;
|
|
41
|
+
}
|
|
42
|
+
function describe(document, key, channel, buckets) {
|
|
43
|
+
const bucket = buckets.get(key);
|
|
44
|
+
const toServer = [...bucket?.toServer ?? []].sort();
|
|
45
|
+
const toClient = [...bucket?.toClient ?? []].sort();
|
|
46
|
+
const info = {
|
|
47
|
+
key,
|
|
48
|
+
name: channel.title ?? key,
|
|
49
|
+
address: channel.address ?? "",
|
|
50
|
+
toServer,
|
|
51
|
+
toClient,
|
|
52
|
+
heartbeat: toServer.some((name) => actionOf$1(document, name) === "ping") && toClient.some((name) => actionOf$1(document, name) === "pong")
|
|
53
|
+
};
|
|
54
|
+
if (channel.description) info.description = channel.description;
|
|
55
|
+
if (channel["x-topic"]) info.topic = channel["x-topic"];
|
|
56
|
+
return info;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Split channels into connections and the topics riding on them.
|
|
60
|
+
*
|
|
61
|
+
* A topic channel shares its connection's address, which is what groups the two;
|
|
62
|
+
* chanx's own Python generator uses the same rule. A topic channel with no plain
|
|
63
|
+
* channel at its address owns its connection, so it is emitted as both.
|
|
64
|
+
*/
|
|
65
|
+
function analyze(document) {
|
|
66
|
+
const buckets = directions(document);
|
|
67
|
+
const channels = Object.entries(document.channels ?? {}).map(([key, channel]) => describe(document, key, channel, buckets));
|
|
68
|
+
const connections = channels.filter((channel) => !channel.topic);
|
|
69
|
+
const topics = channels.filter((channel) => channel.topic);
|
|
70
|
+
const byAddress = /* @__PURE__ */ new Map();
|
|
71
|
+
const result = connections.map((channel) => {
|
|
72
|
+
const connection = {
|
|
73
|
+
...channel,
|
|
74
|
+
topics: []
|
|
75
|
+
};
|
|
76
|
+
byAddress.set(channel.address, connection);
|
|
77
|
+
return connection;
|
|
78
|
+
});
|
|
79
|
+
for (const topic of topics) {
|
|
80
|
+
const parent = byAddress.get(topic.address);
|
|
81
|
+
if (parent) {
|
|
82
|
+
parent.topics.push(topic);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const standalone = {
|
|
86
|
+
...topic,
|
|
87
|
+
topics: [topic]
|
|
88
|
+
};
|
|
89
|
+
byAddress.set(topic.address, standalone);
|
|
90
|
+
result.push(standalone);
|
|
91
|
+
}
|
|
92
|
+
return result.sort((a, b) => a.key.localeCompare(b.key));
|
|
93
|
+
}
|
|
94
|
+
//#endregion
|
|
95
|
+
//#region src/naming.ts
|
|
96
|
+
const SEPARATORS = /[^A-Za-z0-9]+/;
|
|
97
|
+
/**
|
|
98
|
+
* PascalCase that preserves casing inside each part.
|
|
99
|
+
*
|
|
100
|
+
* `ag_ui_run` becomes `AgUiRun`, and `PingMessage` stays `PingMessage`. A
|
|
101
|
+
* `title()`-style pass would flatten it to `Pingmessage`.
|
|
102
|
+
*/
|
|
103
|
+
function pascalCase(value) {
|
|
104
|
+
return value.split(SEPARATORS).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
105
|
+
}
|
|
106
|
+
function camelCase(value) {
|
|
107
|
+
const pascal = pascalCase(value);
|
|
108
|
+
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
109
|
+
}
|
|
110
|
+
/** Quote a key only when it is not a valid identifier. */
|
|
111
|
+
function propertyKey(name) {
|
|
112
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
|
|
113
|
+
}
|
|
114
|
+
function docComment(text, indent = "") {
|
|
115
|
+
if (!text) return "";
|
|
116
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
117
|
+
if (!collapsed) return "";
|
|
118
|
+
return `${indent}/** ${collapsed.replace(/\*\//g, "*\\/")} */\n`;
|
|
119
|
+
}
|
|
120
|
+
//#endregion
|
|
121
|
+
//#region src/emit-channels.ts
|
|
122
|
+
function unionType(name, members) {
|
|
123
|
+
return `export type ${name} = ${members.length ? members.join(" | ") : "never"};\n`;
|
|
124
|
+
}
|
|
125
|
+
function validatorsLiteral(prefix, indent) {
|
|
126
|
+
return `${indent}validators: {\n${indent} toServer: (value) => ${prefix}ToServerSchema.parse(value),\n${indent} toClient: (value) => ${prefix}ToClientSchema.parse(value),\n${indent}},\n`;
|
|
127
|
+
}
|
|
128
|
+
/** Type arguments are erased in JS, so the runtime file calls the plain form. */
|
|
129
|
+
function typeArgs(prefix, target) {
|
|
130
|
+
return target === "ts" ? `<${prefix}ToServer, ${prefix}ToClient>` : "";
|
|
131
|
+
}
|
|
132
|
+
function renderTopic(topic, options, indent) {
|
|
133
|
+
const prefix = pascalCase(topic.name);
|
|
134
|
+
const key = camelCase(topic.topic?.name ?? topic.name);
|
|
135
|
+
const inner = `${indent} `;
|
|
136
|
+
return docComment(topic.description, indent) + `${indent}${propertyKey(key)}: defineTopic${typeArgs(prefix, options.target)}()({\n${inner}name: ${JSON.stringify(topic.topic?.name ?? topic.name)},\n${inner}pattern: ${JSON.stringify(topic.topic?.pattern ?? "")},\n` + (options.withZod ? validatorsLiteral(prefix, inner) : "") + `${indent}}),\n`;
|
|
137
|
+
}
|
|
138
|
+
function collectPrefixes(connection) {
|
|
139
|
+
return [connection, ...connection.topics].map((channel) => pascalCase(channel.name));
|
|
140
|
+
}
|
|
141
|
+
function zodImport(connections, zodModule) {
|
|
142
|
+
return `import {\n ${[...new Set(connections.flatMap(collectPrefixes))].sort().flatMap((prefix) => [`${prefix}ToServerSchema`, `${prefix}ToClientSchema`]).join(",\n ")},\n} from '${zodModule}';\n`;
|
|
143
|
+
}
|
|
144
|
+
/** Declare each channel's message unions exactly once. */
|
|
145
|
+
function unions(connections) {
|
|
146
|
+
const lines = [];
|
|
147
|
+
const declared = /* @__PURE__ */ new Set();
|
|
148
|
+
for (const connection of connections) {
|
|
149
|
+
for (const channel of [connection, ...connection.topics]) {
|
|
150
|
+
const prefix = pascalCase(channel.name);
|
|
151
|
+
if (declared.has(prefix)) continue;
|
|
152
|
+
declared.add(prefix);
|
|
153
|
+
lines.push(unionType(`${prefix}ToServer`, channel.toServer));
|
|
154
|
+
lines.push(unionType(`${prefix}ToClient`, channel.toClient));
|
|
155
|
+
}
|
|
156
|
+
lines.push("\n");
|
|
157
|
+
}
|
|
158
|
+
return lines.join("");
|
|
159
|
+
}
|
|
160
|
+
/** The descriptor values. Valid TypeScript or JavaScript depending on `target`. */
|
|
161
|
+
function emitChannels(connections, messageNames, options) {
|
|
162
|
+
const lines = [`import { defineChannel, defineTopic } from '@chanx-js/client';\n\n`];
|
|
163
|
+
if (options.target === "ts") {
|
|
164
|
+
if (messageNames.length) {
|
|
165
|
+
const imported = [...messageNames].sort().join(",\n ");
|
|
166
|
+
lines.push(`import type {\n ${imported},\n} from '${options.schemasModule}';\n`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (options.withZod) lines.push(zodImport(connections, options.zodModule));
|
|
170
|
+
lines.push("\n");
|
|
171
|
+
if (options.target === "ts") lines.push(unions(connections));
|
|
172
|
+
for (const connection of connections) {
|
|
173
|
+
const prefix = pascalCase(connection.name);
|
|
174
|
+
const constName = camelCase(connection.name);
|
|
175
|
+
lines.push(docComment(connection.description));
|
|
176
|
+
lines.push(`export const ${constName} = defineChannel${typeArgs(prefix, options.target)}()({\n`);
|
|
177
|
+
lines.push(` name: ${JSON.stringify(connection.name)},\n`);
|
|
178
|
+
lines.push(` address: ${JSON.stringify(connection.address)},\n`);
|
|
179
|
+
if (connection.heartbeat) lines.push(" heartbeat: true,\n");
|
|
180
|
+
if (options.withZod) lines.push(validatorsLiteral(prefix, " "));
|
|
181
|
+
if (connection.topics.length) {
|
|
182
|
+
lines.push(" topics: {\n");
|
|
183
|
+
for (const topic of connection.topics) lines.push(renderTopic(topic, options, " "));
|
|
184
|
+
lines.push(" },\n");
|
|
185
|
+
}
|
|
186
|
+
lines.push("});\n\n");
|
|
187
|
+
}
|
|
188
|
+
const registry = connections.map((connection) => ` ${propertyKey(camelCase(connection.name))}`).join(",\n");
|
|
189
|
+
const asConst = options.target === "ts" ? " as const" : "";
|
|
190
|
+
lines.push(`export const channels = {\n${registry},\n}${asConst};\n`);
|
|
191
|
+
return lines.join("");
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* The `.d.ts` companion to a JavaScript `channels.js`.
|
|
195
|
+
*
|
|
196
|
+
* Declares the same descriptors with their full generic arguments, so a plain-JS project
|
|
197
|
+
* still gets narrowing, address-param checking and topic typing from its editor.
|
|
198
|
+
*/
|
|
199
|
+
function emitChannelsDeclaration(connections, messageNames, options) {
|
|
200
|
+
const lines = [`import type { ChannelDescriptor, TopicDescriptor } from '@chanx-js/client';\n`];
|
|
201
|
+
if (messageNames.length) {
|
|
202
|
+
const imported = [...messageNames].sort().join(",\n ");
|
|
203
|
+
lines.push(`import type {\n ${imported},\n} from '${options.schemasModule}';\n`);
|
|
204
|
+
}
|
|
205
|
+
lines.push("\n");
|
|
206
|
+
lines.push(unions(connections));
|
|
207
|
+
for (const connection of connections) {
|
|
208
|
+
const prefix = pascalCase(connection.name);
|
|
209
|
+
const constName = camelCase(connection.name);
|
|
210
|
+
const address = JSON.stringify(connection.address);
|
|
211
|
+
const topics = connection.topics.map((topic) => {
|
|
212
|
+
const topicPrefix = pascalCase(topic.name);
|
|
213
|
+
const key = camelCase(topic.topic?.name ?? topic.name);
|
|
214
|
+
const pattern = JSON.stringify(topic.topic?.pattern ?? "");
|
|
215
|
+
return ` ${propertyKey(key)}: TopicDescriptor<${topicPrefix}ToServer, ${topicPrefix}ToClient, ${pattern}>;`;
|
|
216
|
+
}).join("\n");
|
|
217
|
+
const topicsType = connection.topics.length ? `{\n${topics}\n}` : "Record<never, never>";
|
|
218
|
+
lines.push(docComment(connection.description));
|
|
219
|
+
lines.push(`export declare const ${constName}: ChannelDescriptor<${prefix}ToServer, ${prefix}ToClient, ${address}, ${topicsType}>;\n\n`);
|
|
220
|
+
}
|
|
221
|
+
const registry = connections.map((connection) => {
|
|
222
|
+
const constName = camelCase(connection.name);
|
|
223
|
+
return ` ${propertyKey(constName)}: typeof ${constName};`;
|
|
224
|
+
}).join("\n");
|
|
225
|
+
lines.push(`export declare const channels: {\n${registry}\n};\n`);
|
|
226
|
+
return lines.join("");
|
|
227
|
+
}
|
|
228
|
+
//#endregion
|
|
229
|
+
//#region src/emit-types.ts
|
|
230
|
+
const SCALARS$1 = {
|
|
231
|
+
string: "string",
|
|
232
|
+
integer: "number",
|
|
233
|
+
number: "number",
|
|
234
|
+
boolean: "boolean",
|
|
235
|
+
null: "null"
|
|
236
|
+
};
|
|
237
|
+
/**
|
|
238
|
+
* Whether a property is always present on the wire.
|
|
239
|
+
*
|
|
240
|
+
* chanx gives every message's `action` a `const` *and* a default, so Pydantic leaves it
|
|
241
|
+
* out of `required`. Emitting it optional would put `undefined` in the discriminant's
|
|
242
|
+
* domain and break both narrowing and exhaustiveness checks, so a `const` counts as
|
|
243
|
+
* present regardless of `required`.
|
|
244
|
+
*/
|
|
245
|
+
function isAlwaysPresent(name, property, required) {
|
|
246
|
+
return required.has(name) || property.const !== void 0;
|
|
247
|
+
}
|
|
248
|
+
function tsType(schema, indent = "") {
|
|
249
|
+
if (!schema) return "unknown";
|
|
250
|
+
if (schema.$ref) return refName(schema.$ref);
|
|
251
|
+
if (schema.const !== void 0) return JSON.stringify(schema.const);
|
|
252
|
+
if (schema.enum) return schema.enum.map((value) => JSON.stringify(value)).join(" | ");
|
|
253
|
+
const variants = schema.anyOf ?? schema.oneOf;
|
|
254
|
+
if (variants) return [...new Set(variants.map((variant) => tsType(variant, indent)))].join(" | ");
|
|
255
|
+
if (schema.allOf?.length === 1) return tsType(schema.allOf[0], indent);
|
|
256
|
+
if (schema.allOf?.length) return schema.allOf.map((part) => tsType(part, indent)).join(" & ");
|
|
257
|
+
const suffix = schema.nullable ? " | null" : "";
|
|
258
|
+
const type = schema.type;
|
|
259
|
+
if (Array.isArray(type)) return type.map((entry) => tsType({
|
|
260
|
+
...schema,
|
|
261
|
+
type: entry
|
|
262
|
+
}, indent)).join(" | ");
|
|
263
|
+
if (type === "array") return `Array<${tsType(schema.items, indent)}>${suffix}`;
|
|
264
|
+
if (type === "object" || schema.properties) {
|
|
265
|
+
if (schema.properties) return objectBody(schema, indent) + suffix;
|
|
266
|
+
const additional = schema.additionalProperties;
|
|
267
|
+
if (additional && typeof additional === "object") return `Record<string, ${tsType(additional, indent)}>${suffix}`;
|
|
268
|
+
return `Record<string, unknown>${suffix}`;
|
|
269
|
+
}
|
|
270
|
+
if (typeof type === "string" && type in SCALARS$1) return `${SCALARS$1[type]}${suffix}`;
|
|
271
|
+
return `unknown${suffix}`;
|
|
272
|
+
}
|
|
273
|
+
function objectBody(schema, indent) {
|
|
274
|
+
const required = new Set(schema.required ?? []);
|
|
275
|
+
const inner = `${indent} `;
|
|
276
|
+
const lines = Object.entries(schema.properties ?? {}).map(([name, property]) => {
|
|
277
|
+
const optional = isAlwaysPresent(name, property, required) ? "" : "?";
|
|
278
|
+
return `${docComment(property.description, inner)}${inner}${propertyKey(name)}${optional}: ${tsType(property, inner)};`;
|
|
279
|
+
});
|
|
280
|
+
if (lines.length === 0) return "Record<string, never>";
|
|
281
|
+
return `{\n${lines.join("\n")}\n${indent}}`;
|
|
282
|
+
}
|
|
283
|
+
/** Emit a named declaration, preferring `interface` for plain object shapes. */
|
|
284
|
+
function declareType(name, schema) {
|
|
285
|
+
const doc = docComment(schema.description);
|
|
286
|
+
if (Boolean(schema.properties) && !schema.anyOf && !schema.oneOf && !schema.allOf && !schema.$ref) return `${doc}export interface ${name} ${objectBody(schema, "")}\n`;
|
|
287
|
+
return `${doc}export type ${name} = ${tsType(schema)};\n`;
|
|
288
|
+
}
|
|
289
|
+
function emitSchemas(schemas, skip) {
|
|
290
|
+
return Object.keys(schemas).sort().filter((name) => !skip.has(name)).map((name) => declareType(name, schemas[name])).join("\n");
|
|
291
|
+
}
|
|
292
|
+
//#endregion
|
|
293
|
+
//#region src/emit-zod.ts
|
|
294
|
+
const SCALARS = {
|
|
295
|
+
string: "z.string()",
|
|
296
|
+
integer: "z.number().int()",
|
|
297
|
+
number: "z.number()",
|
|
298
|
+
boolean: "z.boolean()",
|
|
299
|
+
null: "z.null()"
|
|
300
|
+
};
|
|
301
|
+
function zodType(schema) {
|
|
302
|
+
if (!schema) return "z.unknown()";
|
|
303
|
+
if (schema.$ref) return `z.lazy(() => ${refName(schema.$ref)}Schema)`;
|
|
304
|
+
if (schema.const !== void 0) return `z.literal(${JSON.stringify(schema.const)})`;
|
|
305
|
+
if (schema.enum) {
|
|
306
|
+
const members = schema.enum.map((value) => `z.literal(${JSON.stringify(value)})`);
|
|
307
|
+
return members.length === 1 ? members[0] : `z.union([${members.join(", ")}])`;
|
|
308
|
+
}
|
|
309
|
+
const variants = schema.anyOf ?? schema.oneOf;
|
|
310
|
+
if (variants?.length) {
|
|
311
|
+
if (variants.length === 1) return zodType(variants[0]);
|
|
312
|
+
return `z.union([${variants.map(zodType).join(", ")}])`;
|
|
313
|
+
}
|
|
314
|
+
if (schema.allOf?.length === 1) return zodType(schema.allOf[0]);
|
|
315
|
+
if (schema.allOf?.length) return schema.allOf.map(zodType).reduce((left, right) => `z.intersection(${left}, ${right})`);
|
|
316
|
+
const type = schema.type;
|
|
317
|
+
if (Array.isArray(type)) return `z.union([${type.map((entry) => zodType({
|
|
318
|
+
...schema,
|
|
319
|
+
type: entry
|
|
320
|
+
})).join(", ")}])`;
|
|
321
|
+
if (type === "array") return `z.array(${zodType(schema.items)})`;
|
|
322
|
+
if (type === "object" || schema.properties) {
|
|
323
|
+
if (schema.properties) return zodObject(schema);
|
|
324
|
+
const additional = schema.additionalProperties;
|
|
325
|
+
return typeof additional === "object" ? `z.record(z.string(), ${zodType(additional)})` : "z.record(z.string(), z.unknown())";
|
|
326
|
+
}
|
|
327
|
+
const base = (typeof type === "string" ? SCALARS[type] : void 0) ?? "z.unknown()";
|
|
328
|
+
return schema.nullable ? `${base}.nullable()` : base;
|
|
329
|
+
}
|
|
330
|
+
function zodObject(schema) {
|
|
331
|
+
const required = new Set(schema.required ?? []);
|
|
332
|
+
return `z.object({\n${Object.entries(schema.properties ?? {}).map(([name, property]) => {
|
|
333
|
+
const present = required.has(name) || property.const !== void 0;
|
|
334
|
+
const value = zodType(property);
|
|
335
|
+
return ` ${propertyKey(name)}: ${present ? value : `${value}.optional()`},`;
|
|
336
|
+
}).join("\n")}\n})`;
|
|
337
|
+
}
|
|
338
|
+
/** The `action` constant a message schema pins, if it pins one. */
|
|
339
|
+
function actionOf(schema) {
|
|
340
|
+
return schema?.properties?.action?.const;
|
|
341
|
+
}
|
|
342
|
+
function emitZod(schemas, connections, skip) {
|
|
343
|
+
const lines = [`import { z } from 'zod';\n\n`];
|
|
344
|
+
const generated = Object.keys(schemas).sort().filter((name) => !skip.has(name));
|
|
345
|
+
for (const name of [...skip].sort()) {
|
|
346
|
+
const action = actionOf(schemas[name]);
|
|
347
|
+
const schema = action === void 0 ? "z.unknown()" : `z.object({ action: z.literal(${JSON.stringify(action)}) }).catchall(z.unknown())`;
|
|
348
|
+
lines.push(`export const ${name}Schema = ${schema};\n`);
|
|
349
|
+
}
|
|
350
|
+
if (skip.size) lines.push("\n");
|
|
351
|
+
for (const name of generated) lines.push(`export const ${name}Schema = ${zodType(schemas[name])};\n\n`);
|
|
352
|
+
const declared = /* @__PURE__ */ new Set();
|
|
353
|
+
for (const connection of connections) for (const channel of [connection, ...connection.topics]) {
|
|
354
|
+
const prefix = pascalCase(channel.name);
|
|
355
|
+
if (declared.has(prefix)) continue;
|
|
356
|
+
declared.add(prefix);
|
|
357
|
+
lines.push(union(`${prefix}ToServerSchema`, channel.toServer, schemas));
|
|
358
|
+
lines.push(union(`${prefix}ToClientSchema`, channel.toClient, schemas));
|
|
359
|
+
}
|
|
360
|
+
return lines.join("");
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* The `.d.ts` companion to a JavaScript `schemas.zod.js`.
|
|
364
|
+
*
|
|
365
|
+
* Each validator is declared as `z.ZodType<T>`, which is what `parse` needs to return
|
|
366
|
+
* the right type; reproducing the precise zod class by hand buys nothing.
|
|
367
|
+
*/
|
|
368
|
+
function emitZodDeclaration(schemas, connections, skip, schemasModule, channelsModule) {
|
|
369
|
+
const generated = Object.keys(schemas).sort().filter((name) => !skip.has(name));
|
|
370
|
+
const unionNames = [];
|
|
371
|
+
const declared = /* @__PURE__ */ new Set();
|
|
372
|
+
for (const connection of connections) for (const channel of [connection, ...connection.topics]) {
|
|
373
|
+
const prefix = pascalCase(channel.name);
|
|
374
|
+
if (declared.has(prefix)) continue;
|
|
375
|
+
declared.add(prefix);
|
|
376
|
+
unionNames.push(`${prefix}ToServer`, `${prefix}ToClient`);
|
|
377
|
+
}
|
|
378
|
+
const lines = [`import type { z } from 'zod';\n`];
|
|
379
|
+
if (generated.length) lines.push(`import type {\n ${generated.join(",\n ")},\n} from '${schemasModule}';\n`);
|
|
380
|
+
if (unionNames.length) lines.push(`import type {\n ${unionNames.join(",\n ")},\n} from '${channelsModule}';\n`);
|
|
381
|
+
lines.push("\n");
|
|
382
|
+
for (const name of [...skip].sort()) lines.push(`export declare const ${name}Schema: z.ZodType<unknown>;\n`);
|
|
383
|
+
for (const name of generated) lines.push(`export declare const ${name}Schema: z.ZodType<${name}>;\n`);
|
|
384
|
+
for (const name of unionNames) lines.push(`export declare const ${name}Schema: z.ZodType<${name}>;\n`);
|
|
385
|
+
return lines.join("");
|
|
386
|
+
}
|
|
387
|
+
function union(name, members, schemas) {
|
|
388
|
+
if (members.length === 0) return `export const ${name} = z.never();\n`;
|
|
389
|
+
if (members.length === 1) return `export const ${name} = ${members[0]}Schema;\n`;
|
|
390
|
+
const refs = members.map((member) => `${member}Schema`).join(", ");
|
|
391
|
+
const actions = members.map((member) => actionOf(schemas[member]));
|
|
392
|
+
return actions.every((action) => action !== void 0) && new Set(actions).size === actions.length ? `export const ${name} = z.discriminatedUnion('action', [${refs}]);\n` : `export const ${name} = z.union([${refs}]);\n`;
|
|
393
|
+
}
|
|
394
|
+
//#endregion
|
|
395
|
+
//#region src/reuse.ts
|
|
396
|
+
function applyAlias(path, alias) {
|
|
397
|
+
if (!alias) return null;
|
|
398
|
+
const prefixes = Object.keys(alias).sort((a, b) => b.length - a.length);
|
|
399
|
+
for (const prefix of prefixes) if (path.startsWith(prefix)) return alias[prefix] + path.slice(prefix.length);
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
function toSpecifier(filePath, outDir, alias) {
|
|
403
|
+
const aliased = applyAlias(relative(process.cwd(), filePath).replace(/\\/g, "/").replace(/\.(d\.)?tsx?$/, ""), alias);
|
|
404
|
+
if (aliased) return aliased;
|
|
405
|
+
let relativePath = relative(resolve(outDir), filePath).replace(/\\/g, "/");
|
|
406
|
+
relativePath = relativePath.replace(/\.(d\.)?tsx?$/, "");
|
|
407
|
+
return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
|
|
408
|
+
}
|
|
409
|
+
function isInside(filePath, dir) {
|
|
410
|
+
const path = relative(resolve(dir), filePath);
|
|
411
|
+
return path !== "" && !path.startsWith("..") && !isAbsolute(path);
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Find types the project already declares so they are referenced rather than regenerated.
|
|
415
|
+
*
|
|
416
|
+
* Uses the TypeScript AST rather than a line regex: `export type X`, `interface X` and
|
|
417
|
+
* indented declarations are all real cases a regex over `^type\s+(\w+)\s*=` misses.
|
|
418
|
+
* `ts-morph` is loaded only here, so a run without `reuseFrom` never pays for it.
|
|
419
|
+
*/
|
|
420
|
+
async function resolveReuse(wanted, outDir, options) {
|
|
421
|
+
const skip = /* @__PURE__ */ new Set();
|
|
422
|
+
const imports = /* @__PURE__ */ new Map();
|
|
423
|
+
const sources = /* @__PURE__ */ new Map();
|
|
424
|
+
const addImport = (specifier, name) => {
|
|
425
|
+
if (options.ambient) return;
|
|
426
|
+
let names = imports.get(specifier);
|
|
427
|
+
if (!names) {
|
|
428
|
+
names = /* @__PURE__ */ new Set();
|
|
429
|
+
imports.set(specifier, names);
|
|
430
|
+
}
|
|
431
|
+
names.add(name);
|
|
432
|
+
};
|
|
433
|
+
if (options.reuseFrom?.length) {
|
|
434
|
+
const { Project } = await import("ts-morph");
|
|
435
|
+
const project = new Project({
|
|
436
|
+
skipAddingFilesFromTsConfig: true,
|
|
437
|
+
compilerOptions: { allowJs: false }
|
|
438
|
+
});
|
|
439
|
+
project.addSourceFilesAtPaths(options.reuseFrom);
|
|
440
|
+
for (const file of project.getSourceFiles()) {
|
|
441
|
+
if (isInside(file.getFilePath(), outDir)) continue;
|
|
442
|
+
const declared = [
|
|
443
|
+
...file.getTypeAliases(),
|
|
444
|
+
...file.getInterfaces(),
|
|
445
|
+
...file.getEnums(),
|
|
446
|
+
...file.getClasses()
|
|
447
|
+
].filter((declaration) => options.ambient || declaration.isExported()).map((declaration) => declaration.getName() ?? "");
|
|
448
|
+
for (const name of declared) {
|
|
449
|
+
if (!name || !wanted.has(name) || skip.has(name)) continue;
|
|
450
|
+
skip.add(name);
|
|
451
|
+
sources.set(name, file.getFilePath());
|
|
452
|
+
addImport(toSpecifier(file.getFilePath(), outDir, options.alias), name);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
for (const [name, specifier] of Object.entries(options.reuseMap ?? {})) {
|
|
457
|
+
if (!wanted.has(name)) continue;
|
|
458
|
+
skip.add(name);
|
|
459
|
+
sources.set(name, specifier);
|
|
460
|
+
addImport(specifier, name);
|
|
461
|
+
}
|
|
462
|
+
return {
|
|
463
|
+
skip,
|
|
464
|
+
imports,
|
|
465
|
+
sources
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
function renderImports(imports) {
|
|
469
|
+
if (imports.size === 0) return "";
|
|
470
|
+
return `${[...imports.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([specifier, names]) => {
|
|
471
|
+
return `import type { ${[...names].sort().join(", ")} } from '${specifier}';`;
|
|
472
|
+
}).join("\n")}\n\n`;
|
|
473
|
+
}
|
|
474
|
+
//#endregion
|
|
475
|
+
//#region src/generate.ts
|
|
476
|
+
const HEADER = `// Generated by @chanx-js/codegen. Do not edit.\n// Regenerate with: npx @chanx-js/codegen\n\n`;
|
|
477
|
+
async function maybeFormat(code, filePath, enabled) {
|
|
478
|
+
if (!enabled) return code;
|
|
479
|
+
let prettier;
|
|
480
|
+
try {
|
|
481
|
+
prettier = await import("prettier");
|
|
482
|
+
} catch {
|
|
483
|
+
return code;
|
|
484
|
+
}
|
|
485
|
+
const config = await prettier.resolveConfig(filePath);
|
|
486
|
+
return prettier.format(code, {
|
|
487
|
+
...config,
|
|
488
|
+
filepath: filePath
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Fail before writing anything if two schema elements would emit the same identifier.
|
|
493
|
+
* Names are derived by case conversion, so `chat-room` and `chat_room` both become
|
|
494
|
+
* `ChatRoom`, and the output would not compile.
|
|
495
|
+
*/
|
|
496
|
+
function checkNames(connections, schemaNames) {
|
|
497
|
+
const problems = [];
|
|
498
|
+
const claim = (owners, name, owner) => {
|
|
499
|
+
const existing = owners.get(name);
|
|
500
|
+
if (existing === void 0) owners.set(name, owner);
|
|
501
|
+
else if (existing !== owner) problems.push(`"${name}" would be emitted for both ${existing} and ${owner}`);
|
|
502
|
+
};
|
|
503
|
+
const identifiers = /* @__PURE__ */ new Map([["channels", "the channel registry"]]);
|
|
504
|
+
for (const name of schemaNames) {
|
|
505
|
+
claim(identifiers, name, `schema ${name}`);
|
|
506
|
+
claim(identifiers, `${name}Schema`, `schema ${name}`);
|
|
507
|
+
}
|
|
508
|
+
for (const connection of connections) {
|
|
509
|
+
claim(identifiers, camelCase(connection.name), `channel "${connection.key}"`);
|
|
510
|
+
const topicKeys = /* @__PURE__ */ new Map();
|
|
511
|
+
for (const channel of [connection, ...connection.topics]) {
|
|
512
|
+
const owner = `channel "${channel.key}"`;
|
|
513
|
+
const prefix = pascalCase(channel.name);
|
|
514
|
+
for (const suffix of [
|
|
515
|
+
"ToServer",
|
|
516
|
+
"ToClient",
|
|
517
|
+
"ToServerSchema",
|
|
518
|
+
"ToClientSchema"
|
|
519
|
+
]) claim(identifiers, prefix + suffix, owner);
|
|
520
|
+
}
|
|
521
|
+
for (const topic of connection.topics) claim(topicKeys, camelCase(topic.topic?.name ?? topic.name), `channel "${topic.key}"`);
|
|
522
|
+
}
|
|
523
|
+
if (problems.length) throw new Error(`Generated names would clash:\n ${problems.join("\n ")}\nRename the channels, topics or schemas on the server.`);
|
|
524
|
+
}
|
|
525
|
+
function emitReuseCheck(reusedNames, schemas, imports) {
|
|
526
|
+
const declarations = reusedNames.map((name) => declareType(`Generated${name}`, schemas[name])).join("\n");
|
|
527
|
+
const assertions = reusedNames.map((name) => `export type Check${name} = Assert<MutuallyAssignable<${name}, Generated${name}>>;`).join("\n");
|
|
528
|
+
return HEADER + "// Each assertion fails to compile if a reused type has drifted from the schema.\n// Reusing a type assumes the name and the shape both still match.\n\n" + renderImports(imports) + "type MutuallyAssignable<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;\ntype Assert<T extends true> = T;\n\n" + declarations + "\n" + assertions + "\n";
|
|
529
|
+
}
|
|
530
|
+
async function generate(document, options) {
|
|
531
|
+
const target = options.emit ?? "ts";
|
|
532
|
+
if (target !== "ts" && target !== "js") throw new Error(`emit must be "ts" or "js", got "${String(target)}"`);
|
|
533
|
+
const validation = options.validation ?? "none";
|
|
534
|
+
if (validation !== "none" && validation !== "zod") throw new Error(`validation must be "none" or "zod", got "${String(validation)}"`);
|
|
535
|
+
const outDir = resolve(options.outDir);
|
|
536
|
+
const schemas = document.components?.schemas ?? {};
|
|
537
|
+
const connections = analyze(document);
|
|
538
|
+
checkNames(connections, Object.keys(schemas));
|
|
539
|
+
const format = options.format ?? true;
|
|
540
|
+
const withZod = validation === "zod";
|
|
541
|
+
const reuse = await resolveReuse(new Set(Object.keys(schemas)), outDir, options);
|
|
542
|
+
const reusedNames = [...reuse.skip].sort();
|
|
543
|
+
await mkdir(outDir, { recursive: true });
|
|
544
|
+
const files = [];
|
|
545
|
+
const write = async (name, code) => {
|
|
546
|
+
const filePath = join(outDir, name);
|
|
547
|
+
await writeFile(filePath, await maybeFormat(code, filePath, format), "utf-8");
|
|
548
|
+
files.push(filePath);
|
|
549
|
+
};
|
|
550
|
+
const isJs = target === "js";
|
|
551
|
+
await write(isJs ? "schemas.d.ts" : "schemas.ts", HEADER + renderImports(reuse.imports) + emitSchemas(schemas, reuse.skip));
|
|
552
|
+
const messageNames = [...new Set(connections.flatMap((connection) => [connection, ...connection.topics].flatMap((channel) => [...channel.toServer, ...channel.toClient])))];
|
|
553
|
+
const channelOptions = {
|
|
554
|
+
withZod,
|
|
555
|
+
schemasModule: "./schemas",
|
|
556
|
+
zodModule: isJs ? "./schemas.zod.js" : "./schemas.zod",
|
|
557
|
+
target
|
|
558
|
+
};
|
|
559
|
+
await write(isJs ? "channels.js" : "channels.ts", HEADER + emitChannels(connections, messageNames, channelOptions));
|
|
560
|
+
if (isJs) await write("channels.d.ts", HEADER + emitChannelsDeclaration(connections, messageNames, channelOptions));
|
|
561
|
+
if (withZod) {
|
|
562
|
+
await write(isJs ? "schemas.zod.js" : "schemas.zod.ts", HEADER + emitZod(schemas, connections, reuse.skip));
|
|
563
|
+
if (isJs) await write("schemas.zod.d.ts", HEADER + emitZodDeclaration(schemas, connections, reuse.skip, "./schemas", "./channels"));
|
|
564
|
+
}
|
|
565
|
+
if (options.reuseStrict && reusedNames.length) await write(isJs ? "reuse-check.d.ts" : "reuse-check.ts", emitReuseCheck(reusedNames, schemas, reuse.imports));
|
|
566
|
+
if (isJs) {
|
|
567
|
+
const runtime = ["export * from './channels.js';"];
|
|
568
|
+
if (withZod) runtime.push("export * from './schemas.zod.js';");
|
|
569
|
+
await write("index.js", HEADER + runtime.join("\n") + "\n");
|
|
570
|
+
const declarations = ["export * from './schemas';", "export * from './channels';"];
|
|
571
|
+
if (withZod) declarations.push("export * from './schemas.zod';");
|
|
572
|
+
await write("index.d.ts", HEADER + declarations.join("\n") + "\n");
|
|
573
|
+
} else {
|
|
574
|
+
const exports = ["export * from './schemas';", "export * from './channels';"];
|
|
575
|
+
if (withZod) exports.push("export * from './schemas.zod';");
|
|
576
|
+
await write("index.ts", HEADER + exports.join("\n") + "\n");
|
|
577
|
+
}
|
|
578
|
+
return {
|
|
579
|
+
files,
|
|
580
|
+
generatedTypes: Object.keys(schemas).length - reuse.skip.size,
|
|
581
|
+
reusedTypes: reusedNames,
|
|
582
|
+
channels: connections.length,
|
|
583
|
+
topics: connections.reduce((total, connection) => total + connection.topics.length, 0)
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
//#endregion
|
|
587
|
+
//#region src/loader.ts
|
|
588
|
+
/** Load an AsyncAPI document from a URL or a path. JSON and YAML both parse. */
|
|
589
|
+
async function loadSchema(input) {
|
|
590
|
+
let text;
|
|
591
|
+
if (/^https?:\/\//.test(input)) {
|
|
592
|
+
const response = await fetch(input, { signal: AbortSignal.timeout(3e4) });
|
|
593
|
+
if (!response.ok) throw new Error(`Failed to fetch schema: ${response.status} ${response.statusText}`);
|
|
594
|
+
text = await response.text();
|
|
595
|
+
} else text = await readFile(resolve(input), "utf-8");
|
|
596
|
+
const document = parse(text);
|
|
597
|
+
if (!document || typeof document !== "object") throw new Error("Schema did not parse to an object");
|
|
598
|
+
if (!document.channels) throw new Error("Schema has no `channels`: is this an AsyncAPI document?");
|
|
599
|
+
if (!String(document.asyncapi ?? "").startsWith("3.")) throw new Error(`Expected an AsyncAPI 3 document, got asyncapi: ${String(document.asyncapi ?? "missing")}`);
|
|
600
|
+
return document;
|
|
601
|
+
}
|
|
602
|
+
//#endregion
|
|
603
|
+
export { emitZod as a, emitSchemas as c, emitChannelsDeclaration as d, camelCase as f, resolveReuse as i, tsType as l, analyze as m, generate as n, emitZodDeclaration as o, pascalCase as p, renderImports as r, declareType as s, loadSchema as t, emitChannels as u };
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chanx-js/codegen",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Generate TypeScript channel descriptors and message types from a chanx AsyncAPI 3 schema.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Huy Nguyen",
|
|
7
|
+
"homepage": "https://huynguyengl99.github.io/chanx-js/guide/codegen",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/huynguyengl99/chanx-js.git",
|
|
11
|
+
"directory": "packages/codegen"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/huynguyengl99/chanx-js/issues",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"bin": {
|
|
19
|
+
"chanx-codegen": "./dist/cli.mjs"
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.mts",
|
|
24
|
+
"default": "./dist/index.mjs"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsdown src/index.ts src/cli.ts --format esm --dts --clean",
|
|
29
|
+
"typecheck": "tsc -p tsconfig.json"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"cac": "^6.7.14",
|
|
33
|
+
"ts-morph": "^28.0.0",
|
|
34
|
+
"yaml": "^2.7.0"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"prettier": ">=3"
|
|
38
|
+
},
|
|
39
|
+
"peerDependenciesMeta": {
|
|
40
|
+
"prettier": {
|
|
41
|
+
"optional": true
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=20"
|
|
46
|
+
},
|
|
47
|
+
"keywords": [
|
|
48
|
+
"asyncapi",
|
|
49
|
+
"@chanx-js/client",
|
|
50
|
+
"codegen",
|
|
51
|
+
"typescript",
|
|
52
|
+
"websocket"
|
|
53
|
+
],
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"zod": "^3.25.76"
|
|
56
|
+
}
|
|
57
|
+
}
|