@grest-ts/asyncapi 0.0.24 → 0.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,258 @@
1
+ import type {GGWebSocketSchema} from "@grest-ts/websocket";
2
+ import {GGHttpServer, GG_HTTP_SERVER} from "@grest-ts/http";
3
+ import {GGLocator} from "@grest-ts/locator";
4
+ import type {AsyncAPIDocument} from "./AsyncApiTypes";
5
+ import {toAsyncApi, ToAsyncApiOptions} from "./toAsyncApi";
6
+
7
+ /**
8
+ * Configuration passed to `customUi` so the user can build their own switcher
9
+ * around the same per-group spec endpoints we serve.
10
+ */
11
+ export interface AsyncApiSwitcherConfig {
12
+ title: string;
13
+ /** Each entry is one spec dropdown choice, in declaration order. */
14
+ urls: Array<{name: string; url: string}>;
15
+ /** Which `name` opens by default. */
16
+ primaryName: string;
17
+ }
18
+
19
+ export interface GGAsyncApiDocsGroupsOptions extends ToAsyncApiOptions {
20
+ /**
21
+ * Map of group label → WebSocket schemas in that group. Each group
22
+ * becomes its own AsyncAPI spec, served at `${specPathPrefix}/${slug}.json`.
23
+ */
24
+ groups: Record<string, GGWebSocketSchema<any, any, any, any, any>[]>;
25
+
26
+ /** Path prefix for spec endpoints. e.g. `/asyncapi` → `/asyncapi/users.json`. */
27
+ specPathPrefix: string;
28
+
29
+ /** Path where the AsyncAPI Studio HTML is served. */
30
+ docsPath: string;
31
+
32
+ /** Which group opens by default. Must be a key of `groups`. */
33
+ primary?: string;
34
+
35
+ /** Build all specs eagerly at construction (default: lazy on first request). */
36
+ eager?: boolean;
37
+
38
+ /** Replace the AsyncAPI Studio HTML entirely. */
39
+ customUi?: (config: AsyncApiSwitcherConfig) => string;
40
+
41
+ /** Override the HTTP server. Defaults to the locator's GG_HTTP_SERVER. */
42
+ http?: GGHttpServer;
43
+ }
44
+
45
+ /**
46
+ * Multi-spec AsyncAPI Studio — one spec per logical group, switched via a
47
+ * small built-in dropdown rendered above the embedded studio.
48
+ *
49
+ * AsyncAPI's react-component does not have a native multi-spec switcher, so
50
+ * this package ships its own minimal one in the HTML template. The switcher
51
+ * is intentionally hidden inside the package (no public API), to keep the
52
+ * surface small.
53
+ *
54
+ * @example
55
+ * GGAsyncApiDocs.registerGroups({
56
+ * groups: {
57
+ * "Chat": [ChatApiSchema],
58
+ * "Notifications": [NotificationApiSchema],
59
+ * },
60
+ * specPathPrefix: "/asyncapi",
61
+ * docsPath: "/asyncapi-docs",
62
+ * });
63
+ */
64
+ export class GGAsyncApiDocsGroups {
65
+ private readonly options: GGAsyncApiDocsGroupsOptions;
66
+ private readonly groupKeys: string[];
67
+ private readonly slugByGroup: Map<string, string>;
68
+ private readonly specCache = new Map<string, AsyncAPIDocument>();
69
+
70
+ static register(options: GGAsyncApiDocsGroupsOptions): void {
71
+ const server = options.http ?? GGLocator.getScope().get(GG_HTTP_SERVER);
72
+ if (!server) throw new Error("GGAsyncApiDocsGroups.register: no HTTP server found. Pass options.http or create a GGHttpServer first.");
73
+ new GGAsyncApiDocsGroups(server, options);
74
+ }
75
+
76
+ constructor(server: GGHttpServer, options: GGAsyncApiDocsGroupsOptions) {
77
+ this.options = options;
78
+ this.groupKeys = Object.keys(options.groups);
79
+ if (this.groupKeys.length === 0) {
80
+ throw new Error("GGAsyncApiDocsGroups: `groups` must contain at least one entry.");
81
+ }
82
+ this.slugByGroup = buildSlugMap(this.groupKeys);
83
+ if (options.primary !== undefined && !this.groupKeys.includes(options.primary)) {
84
+ throw new Error(`GGAsyncApiDocsGroups: \`primary\` must be a key of \`groups\` (got ${options.primary}).`);
85
+ }
86
+ if (options.eager) {
87
+ for (const name of this.groupKeys) this.specCache.set(name, this.buildSpec(name));
88
+ }
89
+ this.registerWith(server);
90
+ }
91
+
92
+ private buildSpec(group: string): AsyncAPIDocument {
93
+ const schemas = this.options.groups[group];
94
+ if (!schemas) throw new Error(`GGAsyncApiDocsGroups: unknown group ${group}`);
95
+ return toAsyncApi(schemas, this.options);
96
+ }
97
+
98
+ public getSpec(group: string): AsyncAPIDocument {
99
+ let spec = this.specCache.get(group);
100
+ if (!spec) {
101
+ spec = this.buildSpec(group);
102
+ this.specCache.set(group, spec);
103
+ }
104
+ return spec;
105
+ }
106
+
107
+ public buildSwitcherConfig(): AsyncApiSwitcherConfig {
108
+ const prefix = normalizePathPrefix(this.options.specPathPrefix);
109
+ const urls = this.groupKeys.map(name => ({
110
+ name,
111
+ url: `${prefix}/${this.slugByGroup.get(name)!}.json`
112
+ }));
113
+ const primaryName = this.options.primary ?? this.groupKeys[0];
114
+ return {title: this.options.title ?? "API", urls, primaryName};
115
+ }
116
+
117
+ public registerWith(server: GGHttpServer): this {
118
+ const prefix = normalizePathPrefix(this.options.specPathPrefix);
119
+
120
+ for (const name of this.groupKeys) {
121
+ const slug = this.slugByGroup.get(name)!;
122
+ server.registerRoute("GET", `${prefix}/${slug}.json`, async (_req, res) => {
123
+ const spec = this.getSpec(name);
124
+ const body = JSON.stringify(spec, null, 2);
125
+ res.writeHead(200, {
126
+ "Content-Type": "application/json",
127
+ "Content-Length": Buffer.byteLength(body)
128
+ });
129
+ res.end(body);
130
+ });
131
+ }
132
+
133
+ const docsPath = this.options.docsPath;
134
+ const serveStudio = async (_req: any, res: any) => {
135
+ const html = this.buildDocsHtml();
136
+ res.writeHead(200, {
137
+ "Content-Type": "text/html; charset=utf-8",
138
+ "Content-Length": Buffer.byteLength(html)
139
+ });
140
+ res.end(html);
141
+ };
142
+ server.registerRoute("GET", docsPath, serveStudio);
143
+ // Wildcard for sidebar deep-link navigation within the studio
144
+ server.registerRoute("GET", docsPath + "/*", serveStudio);
145
+
146
+ return this;
147
+ }
148
+
149
+ private buildDocsHtml(): string {
150
+ const config = this.buildSwitcherConfig();
151
+ if (this.options.customUi) return this.options.customUi(config);
152
+ return buildSwitcherHtml(config);
153
+ }
154
+ }
155
+
156
+ function normalizePathPrefix(prefix: string): string {
157
+ let p = prefix.trim();
158
+ if (!p.startsWith("/")) p = "/" + p;
159
+ return p.replace(/\/+$/, "");
160
+ }
161
+
162
+ function toSlug(name: string): string {
163
+ return name
164
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
165
+ .toLowerCase()
166
+ .replace(/[^a-z0-9]+/g, "-")
167
+ .replace(/^-+|-+$/g, "")
168
+ || "group";
169
+ }
170
+
171
+ function buildSlugMap(keys: string[]): Map<string, string> {
172
+ const slugs = new Map<string, string>();
173
+ const used = new Set<string>();
174
+ for (const key of keys) {
175
+ const slug = toSlug(key);
176
+ if (used.has(slug)) {
177
+ throw new Error(`GGAsyncApiDocsGroups: group names produce duplicate slug "${slug}". Rename one of: ${keys.filter(k => toSlug(k) === slug).join(", ")}`);
178
+ }
179
+ used.add(slug);
180
+ slugs.set(key, slug);
181
+ }
182
+ return slugs;
183
+ }
184
+
185
+ /**
186
+ * AsyncAPI Studio with a small custom switcher above it.
187
+ *
188
+ * The switcher is a plain `<select>` that fetches the chosen spec and
189
+ * re-renders the studio with the new schema. We hide the studio's own
190
+ * sidebar so the page stays uncluttered.
191
+ */
192
+ function buildSwitcherHtml(config: AsyncApiSwitcherConfig): string {
193
+ const optionsHtml = config.urls.map(u =>
194
+ `<option value="${escapeHtml(u.url)}"${u.name === config.primaryName ? " selected" : ""}>${escapeHtml(u.name)}</option>`
195
+ ).join("");
196
+ return `<!DOCTYPE html>
197
+ <html lang="en">
198
+ <head>
199
+ <meta charset="UTF-8" />
200
+ <title>${escapeHtml(config.title)}</title>
201
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
202
+ <link rel="icon" href="https://www.asyncapi.com/favicon.ico" />
203
+ <link rel="stylesheet" href="https://unpkg.com/@asyncapi/react-component@latest/styles/default.min.css">
204
+ <style>
205
+ body { margin: 0; font-family: system-ui, -apple-system, sans-serif; }
206
+ .gg-switcher {
207
+ padding: 12px 20px;
208
+ background: #1a1a2e;
209
+ color: #fff;
210
+ display: flex;
211
+ align-items: center;
212
+ gap: 16px;
213
+ border-bottom: 1px solid #2a2a3e;
214
+ }
215
+ .gg-switcher-title { font-weight: 600; font-size: 16px; }
216
+ .gg-switcher select {
217
+ padding: 6px 12px;
218
+ font-size: 14px;
219
+ background: #2a2a3e;
220
+ color: #fff;
221
+ border: 1px solid #3a3a4e;
222
+ border-radius: 4px;
223
+ cursor: pointer;
224
+ }
225
+ </style>
226
+ </head>
227
+ <body>
228
+ <div class="gg-switcher">
229
+ <span class="gg-switcher-title">${escapeHtml(config.title)}</span>
230
+ <label>
231
+ <span style="margin-right: 8px;">Spec:</span>
232
+ <select id="gg-spec-switcher">${optionsHtml}</select>
233
+ </label>
234
+ </div>
235
+ <div id="asyncapi"></div>
236
+ <script src="https://unpkg.com/@asyncapi/react-component@latest/browser/standalone/index.js"></script>
237
+ <script>
238
+ const switcher = document.getElementById('gg-spec-switcher');
239
+ const container = document.getElementById('asyncapi');
240
+ async function loadSpec(url) {
241
+ const res = await fetch(url);
242
+ const schema = await res.json();
243
+ container.innerHTML = '';
244
+ AsyncApiStandalone.render(
245
+ {schema: schema, config: {show: {sidebar: true}}},
246
+ container
247
+ );
248
+ }
249
+ switcher.addEventListener('change', e => loadSpec(e.target.value));
250
+ loadSpec(switcher.value);
251
+ </script>
252
+ </body>
253
+ </html>`;
254
+ }
255
+
256
+ function escapeHtml(s: string): string {
257
+ return s.replace(/[&<>"']/g, c => ({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"})[c]!);
258
+ }
package/src/index-node.ts CHANGED
@@ -2,6 +2,8 @@ export {toAsyncApi} from "./toAsyncApi";
2
2
  export type {ToAsyncApiOptions} from "./toAsyncApi";
3
3
  export {GGAsyncApiDocs} from "./GGAsyncApiDocs";
4
4
  export type {GGAsyncApiDocsOptions} from "./GGAsyncApiDocs";
5
+ export {GGAsyncApiDocsGroups} from "./GGAsyncApiDocsGroups";
6
+ export type {GGAsyncApiDocsGroupsOptions, AsyncApiSwitcherConfig} from "./GGAsyncApiDocsGroups";
5
7
  // Backward-compatible aliases
6
8
  export {GGAsyncApiDocs as GGAsyncApiServer} from "./GGAsyncApiDocs";
7
9
  export type {GGAsyncApiDocsOptions as GGAsyncApiServerOptions} from "./GGAsyncApiDocs";
package/src/tsconfig.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "//": "THIS FILE IS GENERATED - DO NOT EDIT",
3
- "extends": "../../../tsconfig.base.json",
3
+ "extends": "../../../../tsconfig.base.json",
4
4
  "compilerOptions": {
5
5
  "rootDir": ".",
6
6
  "lib": [