@voyant-travel/hono 0.118.4 → 0.120.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/dist/app.d.ts CHANGED
@@ -17,6 +17,20 @@ export interface LazyMount {
17
17
  prefix: string;
18
18
  load: LazyRoutesLoader;
19
19
  }
20
+ /**
21
+ * A single module route mount recorded at mount time so a build-time generator
22
+ * can produce one self-contained OpenAPI document per module (voyant#2733).
23
+ * Mirrors `ModuleMount` in `./openapi.ts` (structurally identical, declared here
24
+ * to keep the build-time-only openapi module out of the runtime path). `load`
25
+ * returns the sub-app without serving it — eager mounts wrap the already-built
26
+ * routes as `() => routes`; lazy mounts pass their loader.
27
+ */
28
+ export interface ModuleMount {
29
+ moduleName: string;
30
+ /** Absolute surface mount prefix, or `"/"` for absolute `lazyRoutes`. */
31
+ prefix: string;
32
+ load: () => Hono<any, any, any> | Promise<Hono<any, any, any>>;
33
+ }
20
34
  /**
21
35
  * App handle returned alongside the Hono instance. Carries `ready()` for
22
36
  * headless / sibling-process deployments that need to fire the lazy
@@ -59,6 +73,14 @@ export interface VoyantAppExtensions<TBindings = unknown> {
59
73
  * `.openapi()` operations via `mergeLazyOpenApiPaths`. Never read at runtime.
60
74
  */
61
75
  lazyMounts: LazyMount[];
76
+ /**
77
+ * Every module route mount (admin + public, eager + lazy), tagged with its
78
+ * owning module name (voyant#2733). A build-time generator reads this to
79
+ * produce one self-contained OpenAPI document per module via
80
+ * `generateModuleOpenApiDocuments`, keeping the module boundary authoritative
81
+ * rather than guessed from path prefixes. Never read at runtime.
82
+ */
83
+ moduleMounts: ModuleMount[];
62
84
  }
63
85
  /**
64
86
  * Low-level app factory: given an already-resolved `modules`/`extensions` set
package/dist/app.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAQhC,OAAO,EAAE,KAAK,gBAAgB,EAA0C,MAAM,kBAAkB,CAAA;AA+BhG,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAY,eAAe,EAAE,MAAM,YAAY,CAAA;AAI5F,4EAA4E;AAC5E,KAAK,QAAQ,CAAC,SAAS,SAAS,cAAc,IAAI;IAChD,QAAQ,EAAE,SAAS,CAAA;IACnB,SAAS,EAAE,eAAe,CAAA;CAC3B,CAAA;AAuBD;;;;;GAKG;AACH,MAAM,WAAW,SAAS;IACxB,yEAAyE;IACzE,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,gBAAgB,CAAA;CACvB;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,mBAAmB,CAAC,SAAS,GAAG,OAAO;IACtD;;;;;;;;;;;OAWG;IACH,KAAK,CAAC,QAAQ,CAAC,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC1C;;;;;;;;OAQG;IACH,QAAQ,EAAE,OAAO,qBAAqB,EAAE,QAAQ,CAAA;IAChD;;;;;OAKG;IACH,UAAU,EAAE,SAAS,EAAE,CAAA;CACxB;AAED;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,SAAS,SAAS,cAAc,EACvD,MAAM,EAAE,eAAe,CAAC,SAAS,CAAC,GACjC,IAAI,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC,GAAG,mBAAmB,CAAC,SAAS,CAAC,CAue5D"}
1
+ {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAQhC,OAAO,EAAE,KAAK,gBAAgB,EAA0C,MAAM,kBAAkB,CAAA;AA+BhG,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAY,eAAe,EAAE,MAAM,YAAY,CAAA;AAI5F,4EAA4E;AAC5E,KAAK,QAAQ,CAAC,SAAS,SAAS,cAAc,IAAI;IAChD,QAAQ,EAAE,SAAS,CAAA;IACnB,SAAS,EAAE,eAAe,CAAA;CAC3B,CAAA;AAuBD;;;;;GAKG;AACH,MAAM,WAAW,SAAS;IACxB,yEAAyE;IACzE,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,gBAAgB,CAAA;CACvB;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,WAAW;IAC1B,UAAU,EAAE,MAAM,CAAA;IAClB,yEAAyE;IACzE,MAAM,EAAE,MAAM,CAAA;IAEd,IAAI,EAAE,MAAM,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC,CAAA;CAC/D;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,mBAAmB,CAAC,SAAS,GAAG,OAAO;IACtD;;;;;;;;;;;OAWG;IACH,KAAK,CAAC,QAAQ,CAAC,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC1C;;;;;;;;OAQG;IACH,QAAQ,EAAE,OAAO,qBAAqB,EAAE,QAAQ,CAAA;IAChD;;;;;OAKG;IACH,UAAU,EAAE,SAAS,EAAE,CAAA;IACvB;;;;;;OAMG;IACH,YAAY,EAAE,WAAW,EAAE,CAAA;CAC5B;AAED;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,SAAS,SAAS,cAAc,EACvD,MAAM,EAAE,eAAe,CAAC,SAAS,CAAC,GACjC,IAAI,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC,GAAG,mBAAmB,CAAC,SAAS,CAAC,CA+f5D"}
package/dist/app.js CHANGED
@@ -410,56 +410,79 @@ export function mountApp(config) {
410
410
  // them so the prefix logic is single-sourced here. Cheap array pushes — no
411
411
  // eager `import()` and no runtime read.
412
412
  const lazyMounts = [];
413
+ // Per-module OpenAPI generation manifest (voyant#2733). Recorded alongside the
414
+ // mount so the module name + real prefix are single-sourced here; `publicPath`
415
+ // overrides are captured with their actual mount, not re-derived. Eager routes
416
+ // are wrapped as `() => routes` so the generator's loader-based path is
417
+ // uniform across eager + lazy. Webhook routes are intentionally excluded — the
418
+ // per-module docs cover the admin/storefront surfaces only.
419
+ const moduleMounts = [];
413
420
  // Mount module routes
414
421
  for (const mod of allModules) {
415
- const adminPrefix = `/v1/admin/${mod.module.name}`;
416
- const publicPrefix = resolveSurfaceMountPath("/v1/public", mod.publicPath, mod.module.name);
422
+ const moduleName = mod.module.name;
423
+ const adminPrefix = `/v1/admin/${moduleName}`;
424
+ const publicPrefix = resolveSurfaceMountPath("/v1/public", mod.publicPath, moduleName);
417
425
  if (mod.adminRoutes) {
418
- app.route(adminPrefix, mod.adminRoutes);
426
+ const adminRoutes = mod.adminRoutes;
427
+ app.route(adminPrefix, adminRoutes);
428
+ moduleMounts.push({ moduleName, prefix: adminPrefix, load: () => adminRoutes });
419
429
  }
420
430
  if (mod.publicRoutes) {
421
- app.route(publicPrefix, mod.publicRoutes);
431
+ const publicRoutes = mod.publicRoutes;
432
+ app.route(publicPrefix, publicRoutes);
433
+ moduleMounts.push({ moduleName, prefix: publicPrefix, load: () => publicRoutes });
422
434
  }
423
435
  if (mod.lazyAdminRoutes) {
424
436
  mountLazyRoutesAt(app, adminPrefix, mod.lazyAdminRoutes);
425
437
  lazyMounts.push({ prefix: adminPrefix, load: mod.lazyAdminRoutes });
438
+ moduleMounts.push({ moduleName, prefix: adminPrefix, load: mod.lazyAdminRoutes });
426
439
  }
427
440
  if (mod.lazyPublicRoutes) {
428
441
  mountLazyRoutesAt(app, publicPrefix, mod.lazyPublicRoutes);
429
442
  lazyMounts.push({ prefix: publicPrefix, load: mod.lazyPublicRoutes });
443
+ moduleMounts.push({ moduleName, prefix: publicPrefix, load: mod.lazyPublicRoutes });
430
444
  }
431
445
  if (mod.lazyRoutes) {
432
446
  mountLazyRoutePaths(app, mod.lazyRoutes.paths, mod.lazyRoutes.load);
433
447
  lazyMounts.push({ prefix: "/", load: mod.lazyRoutes.load });
448
+ moduleMounts.push({ moduleName, prefix: "/", load: mod.lazyRoutes.load });
434
449
  }
435
450
  if (mod.webhookRoutes) {
436
- app.route(`/v1/${mod.module.name}`, mod.webhookRoutes);
451
+ app.route(`/v1/${moduleName}`, mod.webhookRoutes);
437
452
  }
438
453
  }
439
454
  // Mount extension routes
440
455
  for (const ext of allExtensions) {
441
- const adminPrefix = `/v1/admin/${ext.extension.module}`;
442
- const publicPrefix = resolveSurfaceMountPath("/v1/public", ext.publicPath, ext.extension.module);
456
+ const moduleName = ext.extension.module;
457
+ const adminPrefix = `/v1/admin/${moduleName}`;
458
+ const publicPrefix = resolveSurfaceMountPath("/v1/public", ext.publicPath, moduleName);
443
459
  if (ext.adminRoutes) {
444
- app.route(adminPrefix, ext.adminRoutes);
460
+ const adminRoutes = ext.adminRoutes;
461
+ app.route(adminPrefix, adminRoutes);
462
+ moduleMounts.push({ moduleName, prefix: adminPrefix, load: () => adminRoutes });
445
463
  }
446
464
  if (ext.publicRoutes) {
447
- app.route(publicPrefix, ext.publicRoutes);
465
+ const publicRoutes = ext.publicRoutes;
466
+ app.route(publicPrefix, publicRoutes);
467
+ moduleMounts.push({ moduleName, prefix: publicPrefix, load: () => publicRoutes });
448
468
  }
449
469
  if (ext.lazyAdminRoutes) {
450
470
  mountLazyRoutesAt(app, adminPrefix, ext.lazyAdminRoutes);
451
471
  lazyMounts.push({ prefix: adminPrefix, load: ext.lazyAdminRoutes });
472
+ moduleMounts.push({ moduleName, prefix: adminPrefix, load: ext.lazyAdminRoutes });
452
473
  }
453
474
  if (ext.lazyPublicRoutes) {
454
475
  mountLazyRoutesAt(app, publicPrefix, ext.lazyPublicRoutes);
455
476
  lazyMounts.push({ prefix: publicPrefix, load: ext.lazyPublicRoutes });
477
+ moduleMounts.push({ moduleName, prefix: publicPrefix, load: ext.lazyPublicRoutes });
456
478
  }
457
479
  if (ext.lazyRoutes) {
458
480
  mountLazyRoutePaths(app, ext.lazyRoutes.paths, ext.lazyRoutes.load);
459
481
  lazyMounts.push({ prefix: "/", load: ext.lazyRoutes.load });
482
+ moduleMounts.push({ moduleName, prefix: "/", load: ext.lazyRoutes.load });
460
483
  }
461
484
  if (ext.webhookRoutes) {
462
- app.route(`/v1/${ext.extension.module}`, ext.webhookRoutes);
485
+ app.route(`/v1/${moduleName}`, ext.webhookRoutes);
463
486
  }
464
487
  }
465
488
  // Additional routes
@@ -477,6 +500,7 @@ export function mountApp(config) {
477
500
  const augmented = app;
478
501
  augmented.eventBus = eventBus;
479
502
  augmented.lazyMounts = lazyMounts;
503
+ augmented.moduleMounts = moduleMounts;
480
504
  augmented.ready = (bindings) => ensureRuntimeBootstrapped(bindings ?? {});
481
505
  return augmented;
482
506
  }
package/dist/openapi.d.ts CHANGED
@@ -66,6 +66,23 @@ export interface LazyMount {
66
66
  prefix: string;
67
67
  load: LazyRoutesLoader;
68
68
  }
69
+ /**
70
+ * A single module route mount recorded by `mountApp` for build-time per-module
71
+ * spec generation (voyant#2733). One module contributes several mounts (admin +
72
+ * public, eager + lazy), all tagged with the same `moduleName` — so the module
73
+ * boundary is the authoritative one from the registration, not a path-prefix
74
+ * guess. `prefix` is the real absolute mount (base path included), so the
75
+ * generated per-module doc carries correct absolute paths — including
76
+ * `publicPath` overrides whose prefix isn't the module name (e.g. a module that
77
+ * mounts its storefront routes under `/v1/public/booking-engine`). `load`
78
+ * returns the sub-app without serving it (eager mounts wrap the already-built
79
+ * app as `() => routes`; lazy mounts pass their loader).
80
+ */
81
+ export interface ModuleMount {
82
+ moduleName: string;
83
+ prefix: string;
84
+ load: () => AnyApp | Promise<AnyApp>;
85
+ }
69
86
  /**
70
87
  * Eager-merge lazy route families into a generated base document (voyant#2114).
71
88
  *
@@ -86,5 +103,81 @@ export interface LazyMount {
86
103
  * (`@asteasolutions/zod-to-openapi` stays out of the Worker bundle).
87
104
  */
88
105
  export declare function mergeLazyOpenApiPaths(base: OpenApiDocument, mounts: readonly LazyMount[], options: GenerateOpenApiOptions): Promise<OpenApiDocument>;
106
+ /**
107
+ * Generate one self-contained OpenAPI document per module from its recorded
108
+ * mounts (voyant#2733).
109
+ *
110
+ * Instead of building one giant composed document and splitting it by path
111
+ * prefix, this generates each module's spec directly from the routes the module
112
+ * registered — the authoritative module boundary. Each module's admin + public
113
+ * (+ lazy) sub-apps are re-mounted into a throwaway `OpenAPIHono` at their real
114
+ * absolute prefix (reusing the exact prefix-merge `OpenAPIHono.route(...)`
115
+ * applies), then its `getOpenAPI31Document()` is read. The returned docs are
116
+ * self-contained (each carries its own referenced components), so they render
117
+ * and diff cleanly on their own — unlike a 7 MB aggregate.
118
+ *
119
+ * A mount whose loader throws, or that returns a plain `Hono` with no
120
+ * `.openapi()` registry, is skipped without failing the module. Modules that
121
+ * contribute no documented operation are omitted from the result entirely.
122
+ *
123
+ * Build-time only — same constraint as the rest of this module
124
+ * (`@asteasolutions/zod-to-openapi` must stay out of the Worker bundle).
125
+ */
126
+ export declare function generateModuleOpenApiDocuments(mounts: readonly ModuleMount[], options: GenerateOpenApiOptions): Promise<Map<string, OpenApiDocument>>;
127
+ /**
128
+ * Build the authoritative path → module ownership map from the mount manifest.
129
+ *
130
+ * Generates each module's isolated doc (see `generateModuleOpenApiDocuments`)
131
+ * only to learn which real absolute paths it owns — so `publicPath` overrides
132
+ * (whose prefix isn't the module name, e.g. `/v1/public/booking-engine`) map to
133
+ * the right module. Routes the manifest doesn't record (`additionalRoutes`,
134
+ * directly-mounted routes) are absent here and fall back to their path segment.
135
+ *
136
+ * Build-time only.
137
+ */
138
+ export declare function buildModulePathOwnership(mounts: readonly ModuleMount[], options: GenerateOpenApiOptions): Promise<Map<string, string>>;
139
+ /**
140
+ * Partition a composed document into one document per module, covering EVERY
141
+ * admin/storefront path (voyant#2733).
142
+ *
143
+ * Uses the ownership map as the authoritative module owner, falling back to the
144
+ * path's own segment for anything the manifest doesn't claim (e.g.
145
+ * `additionalRoutes` mounts like the operator's workflow-runs admin surface).
146
+ * The full surface is therefore partitioned exactly: every `/v1/admin/*` and
147
+ * `/v1/public/*` path lands in exactly one module document. Non-surface routes
148
+ * (`/v1/<name>` webhooks, legacy `/v1/*`) live only in the aggregate, as before.
149
+ *
150
+ * Each per-module document carries the aggregate's shared `components` verbatim
151
+ * (there is ~one), so it stays a valid, self-contained OpenAPI document.
152
+ */
153
+ export declare function partitionByModule(full: OpenApiDocument, owner: ReadonlyMap<string, string>): Map<string, OpenApiDocument>;
154
+ /**
155
+ * Convenience: build the ownership map and partition in one call. Prefer the
156
+ * two-step `buildModulePathOwnership` + `partitionByModule` when you also want
157
+ * to `stampModuleMetadata` the aggregate from the same map (so it's built once).
158
+ *
159
+ * Build-time only.
160
+ */
161
+ export declare function splitDocumentByModule(full: OpenApiDocument, mounts: readonly ModuleMount[], options: GenerateOpenApiOptions): Promise<Map<string, OpenApiDocument>>;
162
+ /**
163
+ * Stamp every operation with the metadata standard OpenAPI tooling expects
164
+ * (voyant#2733 / voyant#2729). All fields are non-destructive — a value a route
165
+ * already declares is never overwritten:
166
+ * - `operationId` — stable camelCase id from method + path, for readable
167
+ * generated client method names.
168
+ * - `summary` — the method + path signature, so viewers/linters have a title
169
+ * for every operation.
170
+ * - `tags: [module]` — so Swagger/Scalar group the sidebar by module (they
171
+ * key grouping off `tags` and ignore `x-*`).
172
+ * - `x-voyant-module` / `x-voyant-surface` — machine-readable owner + surface
173
+ * for custom tooling that shouldn't re-derive them from path prefixes.
174
+ *
175
+ * The module is the authoritative owner from the manifest, so `publicPath`
176
+ * overrides are labelled with their real owning module rather than their mount
177
+ * prefix. Applied to the aggregate before it's split, so the per-module and
178
+ * surface documents (all derived from it) inherit the stamps. Keys are appended,
179
+ * keeping order deterministic for the drift gate.
180
+ */
181
+ export declare function stampModuleMetadata(doc: OpenApiDocument, owner: ReadonlyMap<string, string>): OpenApiDocument;
89
182
  export {};
90
183
  //# sourceMappingURL=openapi.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"openapi.d.ts","sourceRoot":"","sources":["../src/openapi.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAC/C,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAEhC,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAA;AAExD;;;;;;;;;;;;GAYG;AAEH,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,EAAE,MAAM,CAAA;IACf,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,8EAA8E;IAC9E,CAAC,SAAS,EAAE,KAAK,MAAM,EAAE,GAAG,OAAO,CAAA;CACpC;AAED,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,MAAM,CAAA;IACX,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,CAAC,SAAS,EAAE,KAAK,MAAM,EAAE,GAAG,OAAO,CAAA;CACpC;AAED,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,WAAW,CAAA;IACjB,OAAO,CAAC,EAAE,aAAa,EAAE,CAAA;CAC1B;AAGD,KAAK,MAAM,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAA;AAEjC;;;GAGG;AACH,MAAM,MAAM,eAAe,GAAG,UAAU,CAAC,WAAW,CAAC,sBAAsB,CAAC,CAAC,CAAA;AAE7E;;;;GAIG;AACH,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,sBAAsB,GAC9B,eAAe,CASjB;AAED;;;;GAIG;AACH,MAAM,MAAM,UAAU,GAAG,OAAO,GAAG,YAAY,CAAA;AAO/C;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,eAAe,EAAE,OAAO,EAAE,UAAU,GAAG,eAAe,CAMxF;AAED;;;;;;GAMG;AACH,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,gBAAgB,CAAA;CACvB;AAYD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,qBAAqB,CACzC,IAAI,EAAE,eAAe,EACrB,MAAM,EAAE,SAAS,SAAS,EAAE,EAC5B,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,eAAe,CAAC,CA8D1B"}
1
+ {"version":3,"file":"openapi.d.ts","sourceRoot":"","sources":["../src/openapi.ts"],"names":[],"mappings":"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"}
package/dist/openapi.js CHANGED
@@ -112,3 +112,253 @@ export async function mergeLazyOpenApiPaths(base, mounts, options) {
112
112
  }
113
113
  return merged;
114
114
  }
115
+ /**
116
+ * Generate one self-contained OpenAPI document per module from its recorded
117
+ * mounts (voyant#2733).
118
+ *
119
+ * Instead of building one giant composed document and splitting it by path
120
+ * prefix, this generates each module's spec directly from the routes the module
121
+ * registered — the authoritative module boundary. Each module's admin + public
122
+ * (+ lazy) sub-apps are re-mounted into a throwaway `OpenAPIHono` at their real
123
+ * absolute prefix (reusing the exact prefix-merge `OpenAPIHono.route(...)`
124
+ * applies), then its `getOpenAPI31Document()` is read. The returned docs are
125
+ * self-contained (each carries its own referenced components), so they render
126
+ * and diff cleanly on their own — unlike a 7 MB aggregate.
127
+ *
128
+ * A mount whose loader throws, or that returns a plain `Hono` with no
129
+ * `.openapi()` registry, is skipped without failing the module. Modules that
130
+ * contribute no documented operation are omitted from the result entirely.
131
+ *
132
+ * Build-time only — same constraint as the rest of this module
133
+ * (`@asteasolutions/zod-to-openapi` must stay out of the Worker bundle).
134
+ */
135
+ export async function generateModuleOpenApiDocuments(mounts, options) {
136
+ const byModule = new Map();
137
+ for (const mount of mounts) {
138
+ const list = byModule.get(mount.moduleName);
139
+ if (list)
140
+ list.push(mount);
141
+ else
142
+ byModule.set(mount.moduleName, [mount]);
143
+ }
144
+ const result = new Map();
145
+ for (const [moduleName, moduleMounts] of byModule) {
146
+ const throwaway = new OpenAPIHono();
147
+ let mountedAny = false;
148
+ for (const { prefix, load } of moduleMounts) {
149
+ let subApp;
150
+ try {
151
+ subApp = await load();
152
+ }
153
+ catch (error) {
154
+ const message = error instanceof Error ? error.message : String(error);
155
+ console.warn(`[voyant] openapi: module "${moduleName}" mount at "${prefix}" failed to load, skipping: ${message}`);
156
+ continue;
157
+ }
158
+ if (!isOpenApiHono(subApp))
159
+ continue;
160
+ throwaway.route(prefix === "/" ? "/" : prefix, subApp);
161
+ mountedAny = true;
162
+ }
163
+ if (!mountedAny)
164
+ continue;
165
+ const doc = throwaway.getOpenAPI31Document({
166
+ openapi: "3.1.0",
167
+ info: options.info,
168
+ ...(options.servers ? { servers: options.servers } : {}),
169
+ });
170
+ if (!doc.paths || Object.keys(doc.paths).length === 0)
171
+ continue;
172
+ result.set(moduleName, doc);
173
+ }
174
+ return result;
175
+ }
176
+ /**
177
+ * The module a path belongs to, given the authoritative owner map: the manifest
178
+ * owner if known (which is what keeps `publicPath` overrides correct), else the
179
+ * path's own module segment — `<seg>` in `/v1/admin/<seg>/...`,
180
+ * `/v1/public/<seg>/...`, or `/v1/<seg>/...` (webhooks/legacy).
181
+ */
182
+ function moduleNameForPath(path, owner) {
183
+ const known = owner.get(path);
184
+ if (known)
185
+ return known;
186
+ const parts = path.split("/").filter(Boolean); // ["v1","admin","bookings",...]
187
+ const seg = parts[1] === "admin" || parts[1] === "public" ? parts[2] : parts[1];
188
+ return seg ?? "misc";
189
+ }
190
+ /** The API surface a path is served on, or `null` for non-surface routes. */
191
+ function surfaceForPath(path) {
192
+ if (path.startsWith("/v1/admin/"))
193
+ return "admin";
194
+ if (path.startsWith("/v1/public/"))
195
+ return "storefront";
196
+ return null;
197
+ }
198
+ /**
199
+ * Build the authoritative path → module ownership map from the mount manifest.
200
+ *
201
+ * Generates each module's isolated doc (see `generateModuleOpenApiDocuments`)
202
+ * only to learn which real absolute paths it owns — so `publicPath` overrides
203
+ * (whose prefix isn't the module name, e.g. `/v1/public/booking-engine`) map to
204
+ * the right module. Routes the manifest doesn't record (`additionalRoutes`,
205
+ * directly-mounted routes) are absent here and fall back to their path segment.
206
+ *
207
+ * Build-time only.
208
+ */
209
+ export async function buildModulePathOwnership(mounts, options) {
210
+ const moduleDocs = await generateModuleOpenApiDocuments(mounts, options);
211
+ const owner = new Map();
212
+ for (const [moduleName, doc] of moduleDocs) {
213
+ for (const path of Object.keys(doc.paths ?? {}))
214
+ owner.set(path, moduleName);
215
+ }
216
+ return owner;
217
+ }
218
+ /**
219
+ * Partition a composed document into one document per module, covering EVERY
220
+ * admin/storefront path (voyant#2733).
221
+ *
222
+ * Uses the ownership map as the authoritative module owner, falling back to the
223
+ * path's own segment for anything the manifest doesn't claim (e.g.
224
+ * `additionalRoutes` mounts like the operator's workflow-runs admin surface).
225
+ * The full surface is therefore partitioned exactly: every `/v1/admin/*` and
226
+ * `/v1/public/*` path lands in exactly one module document. Non-surface routes
227
+ * (`/v1/<name>` webhooks, legacy `/v1/*`) live only in the aggregate, as before.
228
+ *
229
+ * Each per-module document carries the aggregate's shared `components` verbatim
230
+ * (there is ~one), so it stays a valid, self-contained OpenAPI document.
231
+ */
232
+ export function partitionByModule(full, owner) {
233
+ const buckets = new Map();
234
+ for (const [path, item] of Object.entries(full.paths ?? {})) {
235
+ if (!path.startsWith("/v1/admin/") && !path.startsWith("/v1/public/"))
236
+ continue;
237
+ const moduleName = moduleNameForPath(path, owner);
238
+ let bucket = buckets.get(moduleName);
239
+ if (!bucket) {
240
+ bucket = {};
241
+ buckets.set(moduleName, bucket);
242
+ }
243
+ bucket[path] = item;
244
+ }
245
+ const result = new Map();
246
+ for (const [moduleName, paths] of buckets) {
247
+ result.set(moduleName, {
248
+ ...full,
249
+ paths,
250
+ ...(full.components ? { components: full.components } : {}),
251
+ });
252
+ }
253
+ return result;
254
+ }
255
+ /**
256
+ * Convenience: build the ownership map and partition in one call. Prefer the
257
+ * two-step `buildModulePathOwnership` + `partitionByModule` when you also want
258
+ * to `stampModuleMetadata` the aggregate from the same map (so it's built once).
259
+ *
260
+ * Build-time only.
261
+ */
262
+ export async function splitDocumentByModule(full, mounts, options) {
263
+ return partitionByModule(full, await buildModulePathOwnership(mounts, options));
264
+ }
265
+ const HTTP_METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
266
+ /** PascalCase a path segment, splitting on non-alphanumerics (kebab, etc.). */
267
+ function pascalCase(segment) {
268
+ return segment
269
+ .split(/[^a-zA-Z0-9]+/)
270
+ .filter(Boolean)
271
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
272
+ .join("");
273
+ }
274
+ /**
275
+ * Stable camelCase operationId derived from method + path (voyant#2729) — gives
276
+ * client generators readable, deterministic method names instead of guessing.
277
+ * Path params render as `ByX`, the `v1` prefix is dropped:
278
+ * `GET /v1/admin/bookings/{id}` → `getAdminBookingsById`. Method + path is unique
279
+ * per OpenAPI, so the derived id is too (a numeric suffix guards edge cases).
280
+ */
281
+ function deriveOperationId(method, path) {
282
+ const parts = path
283
+ .split("/")
284
+ .filter(Boolean)
285
+ .filter((segment) => segment !== "v1")
286
+ .map((segment) => {
287
+ const param = /^\{(.+)\}$/.exec(segment);
288
+ return param?.[1] ? `By${pascalCase(param[1])}` : pascalCase(segment);
289
+ });
290
+ return `${method}${parts.join("")}`;
291
+ }
292
+ /**
293
+ * A readable, always-correct operation summary (voyant#2729): the method + path
294
+ * signature. Deliberately mechanical rather than guessed prose — a hand-authored
295
+ * per-route `summary` overrides it (forward-only).
296
+ */
297
+ function deriveSummary(method, path) {
298
+ return `${method.toUpperCase()} ${path}`;
299
+ }
300
+ /**
301
+ * Stamp every operation with the metadata standard OpenAPI tooling expects
302
+ * (voyant#2733 / voyant#2729). All fields are non-destructive — a value a route
303
+ * already declares is never overwritten:
304
+ * - `operationId` — stable camelCase id from method + path, for readable
305
+ * generated client method names.
306
+ * - `summary` — the method + path signature, so viewers/linters have a title
307
+ * for every operation.
308
+ * - `tags: [module]` — so Swagger/Scalar group the sidebar by module (they
309
+ * key grouping off `tags` and ignore `x-*`).
310
+ * - `x-voyant-module` / `x-voyant-surface` — machine-readable owner + surface
311
+ * for custom tooling that shouldn't re-derive them from path prefixes.
312
+ *
313
+ * The module is the authoritative owner from the manifest, so `publicPath`
314
+ * overrides are labelled with their real owning module rather than their mount
315
+ * prefix. Applied to the aggregate before it's split, so the per-module and
316
+ * surface documents (all derived from it) inherit the stamps. Keys are appended,
317
+ * keeping order deterministic for the drift gate.
318
+ */
319
+ export function stampModuleMetadata(doc, owner) {
320
+ const paths = {};
321
+ // operationId must be unique across the document; track what we've assigned
322
+ // (including route-declared ids) so a derived id never collides.
323
+ const usedOperationIds = new Set();
324
+ for (const [path, item] of Object.entries(doc.paths ?? {})) {
325
+ if (!item || typeof item !== "object") {
326
+ paths[path] = item;
327
+ continue;
328
+ }
329
+ const moduleName = moduleNameForPath(path, owner);
330
+ const surface = surfaceForPath(path);
331
+ const nextItem = { ...item };
332
+ for (const method of HTTP_METHODS) {
333
+ const op = nextItem[method];
334
+ if (!op || typeof op !== "object")
335
+ continue;
336
+ const operation = op;
337
+ const declaredId = typeof operation.operationId === "string" && operation.operationId.length > 0
338
+ ? operation.operationId
339
+ : null;
340
+ let operationId = declaredId ?? deriveOperationId(method, path);
341
+ if (!declaredId) {
342
+ let suffix = 2;
343
+ while (usedOperationIds.has(operationId)) {
344
+ operationId = `${deriveOperationId(method, path)}_${suffix++}`;
345
+ }
346
+ }
347
+ usedOperationIds.add(operationId);
348
+ const hasSummary = typeof operation.summary === "string" && operation.summary.length > 0;
349
+ // Swagger/Scalar group by `tags` (and ignore `x-*`) — without one a
350
+ // whole-surface document collapses under a single "default" group (#2733).
351
+ const hasTags = Array.isArray(operation.tags) && operation.tags.length > 0;
352
+ nextItem[method] = {
353
+ ...operation,
354
+ operationId,
355
+ ...(hasSummary ? {} : { summary: deriveSummary(method, path) }),
356
+ ...(hasTags ? {} : { tags: [moduleName] }),
357
+ "x-voyant-module": moduleName,
358
+ ...(surface ? { "x-voyant-surface": surface } : {}),
359
+ };
360
+ }
361
+ paths[path] = nextItem;
362
+ }
363
+ return { ...doc, paths };
364
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voyant-travel/hono",
3
- "version": "0.118.4",
3
+ "version": "0.120.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -140,14 +140,14 @@
140
140
  "@voyant-travel/storage": "^0.106.0",
141
141
  "@voyant-travel/types": "^0.106.1",
142
142
  "@voyant-travel/utils": "^0.105.5",
143
- "@voyant-travel/workflows": "^0.111.12"
143
+ "@voyant-travel/workflows": "^0.111.14"
144
144
  },
145
145
  "devDependencies": {
146
146
  "@cloudflare/workers-types": "^4.20260628.1",
147
147
  "typescript": "^6.0.3",
148
148
  "vitest": "^4.1.9",
149
149
  "@voyant-travel/voyant-typescript-config": "^0.1.0",
150
- "@voyant-travel/workflows-orchestrator": "^0.111.12"
150
+ "@voyant-travel/workflows-orchestrator": "^0.111.14"
151
151
  },
152
152
  "files": [
153
153
  "dist"