@velajs/vela 1.8.2 → 1.8.4

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,90 @@
1
+ import type { Hono } from 'hono';
2
+ import type { Type } from '../container/types';
3
+ import type { CanActivate } from '../pipeline/types';
4
+ import type { OpenApiPathItem } from '../openapi/types';
5
+ /**
6
+ * Context passed to {@link CrudBridge.buildRoutes} when mounting CRUD
7
+ * sub-applications for a `@Crud()`-decorated controller.
8
+ *
9
+ * The bridge receives the framework's already-resolved view of the world so
10
+ * it can produce identical routing behavior to a hand-written controller:
11
+ * the same global prefix is prepended to every path, the same global guard
12
+ * instances run before any generated handler, and the same path-joining
13
+ * helper composes URL segments without double slashes.
14
+ */
15
+ export interface CrudBridgeRouteContext {
16
+ /** Application-wide path prefix (e.g. `/api`), already normalized. */
17
+ globalPrefix: string;
18
+ /** Global guard instances, pre-resolved from the root container. */
19
+ globalGuards: CanActivate[];
20
+ /** Path joiner used by the framework — handles slash normalization. */
21
+ joinPaths: (...parts: string[]) => string;
22
+ }
23
+ /**
24
+ * Context passed to {@link CrudBridge.buildOpenApiPaths} when contributing
25
+ * OpenAPI path items for a `@Crud()`-decorated controller.
26
+ *
27
+ * The bridge is responsible for producing fully-qualified path keys that
28
+ * match the routes it actually mounts at request time. The framework supplies
29
+ * the prefixes so the bridge does not have to re-derive them.
30
+ */
31
+ export interface CrudBridgeOpenApiContext {
32
+ /** Application-wide path prefix (e.g. `/api`), already normalized. */
33
+ globalPrefix: string;
34
+ /** Controller-level prefix as registered with `@Controller(...)`. */
35
+ controllerPrefix: string;
36
+ }
37
+ /**
38
+ * Contract between `@velajs/vela` and a CRUD route generator (typically
39
+ * `@velajs/crud`). The bridge inverts the previous dynamic-import pattern,
40
+ * which was incompatible with Cloudflare Workers because esbuild leaves
41
+ * `await import(variable)` calls as runtime imports the Workers loader
42
+ * cannot resolve.
43
+ *
44
+ * A consumer registers an implementation once via {@link registerCrudBridge}
45
+ * — usually as a side effect of importing `@velajs/crud` — and the framework
46
+ * delegates both route building (at bootstrap) and OpenAPI generation
47
+ * (at document creation) to it.
48
+ */
49
+ export interface CrudBridge {
50
+ /**
51
+ * Mount the CRUD sub-app for a controller with `vela:crud` metadata.
52
+ *
53
+ * Called during {@link RouteManager.build} after all `@Get`/`@Post`/etc.
54
+ * routes have been registered. The bridge is expected to attach generated
55
+ * routes (list, read, create, update, delete, …) onto the supplied Hono
56
+ * app under `ctx.globalPrefix + prefix`.
57
+ */
58
+ buildRoutes(app: Hono, controller: Type, prefix: string, crudConfig: unknown, ctx: CrudBridgeRouteContext): Promise<void>;
59
+ /**
60
+ * Contribute OpenAPI path items for a `@Crud()`-decorated controller.
61
+ *
62
+ * Called once per CRUD controller from `createOpenApiDocument` after the
63
+ * standard `@Get`/`@Post`/etc. paths have been collected. The returned
64
+ * record maps full OpenAPI path strings (e.g. `/api/users/{id}`) to path
65
+ * items, and is merged into the document — entries already present from
66
+ * hand-written handlers are preserved verb-by-verb.
67
+ */
68
+ buildOpenApiPaths(controller: Type, crudConfig: unknown, ctx: CrudBridgeOpenApiContext): Record<string, OpenApiPathItem>;
69
+ }
70
+ /**
71
+ * Register a {@link CrudBridge} implementation. Intended to be called once at
72
+ * import time by the CRUD package; subsequent calls overwrite the previous
73
+ * registration (last-writer-wins). This is deliberate so test harnesses can
74
+ * swap in mocks without a teardown step on the global state.
75
+ */
76
+ export declare function registerCrudBridge(bridge: CrudBridge): void;
77
+ /**
78
+ * Return the currently registered {@link CrudBridge}, or `undefined` if no
79
+ * bridge has been registered. Consumers using `@Crud()` without a registered
80
+ * bridge will receive a clear install-it error from the framework at build
81
+ * time — see `RouteManager.build`.
82
+ */
83
+ export declare function getCrudBridge(): CrudBridge | undefined;
84
+ /**
85
+ * Reset the global bridge registration. Test-only — not exported from
86
+ * `@velajs/vela/internal`. Use this in `beforeEach` to keep cases isolated.
87
+ *
88
+ * @internal
89
+ */
90
+ export declare function _resetCrudBridge(): void;
@@ -0,0 +1,25 @@
1
+ let registered;
2
+ /**
3
+ * Register a {@link CrudBridge} implementation. Intended to be called once at
4
+ * import time by the CRUD package; subsequent calls overwrite the previous
5
+ * registration (last-writer-wins). This is deliberate so test harnesses can
6
+ * swap in mocks without a teardown step on the global state.
7
+ */ export function registerCrudBridge(bridge) {
8
+ registered = bridge;
9
+ }
10
+ /**
11
+ * Return the currently registered {@link CrudBridge}, or `undefined` if no
12
+ * bridge has been registered. Consumers using `@Crud()` without a registered
13
+ * bridge will receive a clear install-it error from the framework at build
14
+ * time — see `RouteManager.build`.
15
+ */ export function getCrudBridge() {
16
+ return registered;
17
+ }
18
+ /**
19
+ * Reset the global bridge registration. Test-only — not exported from
20
+ * `@velajs/vela/internal`. Use this in `beforeEach` to keep cases isolated.
21
+ *
22
+ * @internal
23
+ */ export function _resetCrudBridge() {
24
+ registered = undefined;
25
+ }
@@ -4,6 +4,7 @@ import { HttpException } from "../errors/http-exception.js";
4
4
  import { getMetadata } from "../metadata.js";
5
5
  import { joinPaths } from "../registry/paths.js";
6
6
  import { ArgumentResolver } from "./argument-resolver.js";
7
+ import { getCrudBridge } from "./crud-bridge.js";
7
8
  import { buildMiddlewareExecutionContext } from "./execution-context.js";
8
9
  import { HandlerExecutor } from "./handler-executor.js";
9
10
  import { instantiate, instantiateMany } from "./instantiate.js";
@@ -279,23 +280,25 @@ export class RouteManager {
279
280
  }
280
281
  }
281
282
  // Second pass: mount CRUD sub-apps (/:id routes registered last).
283
+ //
284
+ // Routes are produced by a registered CrudBridge — usually contributed
285
+ // by `@velajs/crud` as an import side effect. Inversion was deliberate:
286
+ // a previous implementation called `await import('@velajs/crud')` via
287
+ // an indirect variable to dodge esbuild's static analysis, which made
288
+ // Cloudflare Workers unable to resolve the module at runtime. The
289
+ // bridge contract removes that hazard — no runtime resolver involved.
282
290
  for (const { controller, metadata } of this.controllers){
283
291
  const crudConfig = getMetadata('vela:crud', controller);
284
- if (crudConfig) {
285
- try {
286
- const pkg = '@velajs/crud';
287
- const { buildCrudRoutes } = await import(pkg);
288
- await buildCrudRoutes(app, controller, metadata.prefix, crudConfig, {
289
- globalPrefix: this.globalPrefix,
290
- globalGuards: instantiateMany(this.globalGuards, this.container),
291
- joinPaths
292
- });
293
- } catch (err) {
294
- throw new Error(`@Crud() requires '@velajs/crud'. Install it: pnpm add @velajs/crud`, {
295
- cause: err
296
- });
297
- }
292
+ if (!crudConfig) continue;
293
+ const bridge = getCrudBridge();
294
+ if (!bridge) {
295
+ throw new Error("@Crud() requires '@velajs/crud'. Install it: pnpm add @velajs/crud");
298
296
  }
297
+ await bridge.buildRoutes(app, controller, metadata.prefix, crudConfig, {
298
+ globalPrefix: this.globalPrefix,
299
+ globalGuards: instantiateMany(this.globalGuards, this.container),
300
+ joinPaths
301
+ });
299
302
  }
300
303
  return app;
301
304
  }
@@ -5,6 +5,8 @@ export type { ModuleScope, ContainerOptions, Diagnostics, } from './container/ty
5
5
  export { bindAppProviders } from './pipeline/app-providers';
6
6
  export { RouteManager } from './http/route.manager';
7
7
  export type { RouteManagerOptions } from './http/route.manager';
8
+ export { registerCrudBridge, getCrudBridge, } from './http/crud-bridge';
9
+ export type { CrudBridge, CrudBridgeRouteContext, CrudBridgeOpenApiContext, } from './http/crud-bridge';
8
10
  export { ModuleLoader } from './module/module-loader';
9
11
  export { ComponentManager } from './pipeline/component.manager';
10
12
  export { VelaApplication } from './application';
package/dist/internal.js CHANGED
@@ -6,6 +6,13 @@ export { ModuleRef } from "./container/module-ref.js";
6
6
  export { ModuleVisibilityError } from "./container/types.js";
7
7
  export { bindAppProviders } from "./pipeline/app-providers.js";
8
8
  export { RouteManager } from "./http/route.manager.js";
9
+ // CRUD bridge registry — the integration seam for `@velajs/crud` (and any
10
+ // future CRUD route generator). `@velajs/crud` calls `registerCrudBridge`
11
+ // once at import time; the framework consults `getCrudBridge` whenever it
12
+ // encounters a controller carrying `vela:crud` metadata, both at route
13
+ // build time and at OpenAPI document generation. The types are exposed so
14
+ // integrators can write their own bridges or strongly-typed mocks in tests.
15
+ export { registerCrudBridge, getCrudBridge } from "./http/crud-bridge.js";
9
16
  export { ModuleLoader } from "./module/module-loader.js";
10
17
  export { ComponentManager } from "./pipeline/component.manager.js";
11
18
  export { VelaApplication } from "./application.js";
@@ -1,7 +1,9 @@
1
+ import { METADATA_KEYS, ParamType } from "../constants.js";
2
+ import { getCrudBridge } from "../http/crud-bridge.js";
3
+ import { getMetadata } from "../metadata.js";
1
4
  import { collectControllers } from "../module/graph.js";
2
5
  import { MetadataRegistry } from "../registry/metadata.registry.js";
3
6
  import { joinPaths, toOpenApiPath } from "../registry/paths.js";
4
- import { ParamType } from "../constants.js";
5
7
  import { getApiDoc, getApiResponses, getApiTags } from "./decorators.js";
6
8
  import { isOptional, zodToJsonSchema } from "./zod-to-json-schema.js";
7
9
  /**
@@ -198,7 +200,8 @@ export function createOpenApiDocument(rootModule, options = {}) {
198
200
  const paths = {};
199
201
  const globalPrefix = options.globalPrefix ?? '';
200
202
  const registry = new ComponentsRegistry();
201
- for (const controller of collectControllers(rootModule)){
203
+ const controllers = collectControllers(rootModule);
204
+ for (const controller of controllers){
202
205
  const controllerPath = MetadataRegistry.getControllerPath(controller);
203
206
  const routes = MetadataRegistry.getRoutes(controller);
204
207
  for (const route of routes){
@@ -212,6 +215,60 @@ export function createOpenApiDocument(rootModule, options = {}) {
212
215
  paths[pathString] = pathItem;
213
216
  }
214
217
  }
218
+ // Second pass: include `@Crud()`-generated routes via the registered
219
+ // CrudBridge. The bridge knows how to turn its own metadata config into
220
+ // OpenAPI path items; this loop is silent when no bridge is registered or
221
+ // no controller carries `vela:crud` metadata, so consumers without
222
+ // `@velajs/crud` see no behavioral change.
223
+ const bridge = getCrudBridge();
224
+ if (bridge) {
225
+ for (const controller of controllers){
226
+ const crudConfig = getMetadata(METADATA_KEYS.CRUD, controller);
227
+ if (!crudConfig) continue;
228
+ const controllerPrefix = MetadataRegistry.getControllerPath(controller);
229
+ const crudPaths = bridge.buildOpenApiPaths(controller, crudConfig, {
230
+ globalPrefix,
231
+ controllerPrefix
232
+ });
233
+ for (const [pathKey, pathItem] of Object.entries(crudPaths)){
234
+ // Verb-level merge: a hand-written `@Get('/')` on the same controller
235
+ // is preserved when the bridge contributes `post`/`patch`/etc. on
236
+ // the same path key.
237
+ paths[pathKey] = {
238
+ ...paths[pathKey] ?? {},
239
+ ...pathItem
240
+ };
241
+ }
242
+ }
243
+ }
244
+ // Aggregate the document-root `tags` array. NestJS declares tag
245
+ // groups/descriptions/order via `DocumentBuilder().addTag(name, desc)`
246
+ // while its scanner auto-collects operation tags; we wire both halves
247
+ // here. Walk every operation's `tags` in first-seen order (stable: paths
248
+ // insertion order, then verb order), then merge with `options.tags`
249
+ // (caller-declared tags first — preserving their order + descriptions —
250
+ // followed by any purely-collected tag not already declared, as `{ name }`).
251
+ const seenTagNames = new Set();
252
+ const collectedTagNames = [];
253
+ for (const pathItem of Object.values(paths)){
254
+ for (const verb of VERB_WHITELIST){
255
+ const operation = pathItem[verb];
256
+ if (!operation?.tags) continue;
257
+ for (const tagName of operation.tags){
258
+ if (seenTagNames.has(tagName)) continue;
259
+ seenTagNames.add(tagName);
260
+ collectedTagNames.push(tagName);
261
+ }
262
+ }
263
+ }
264
+ const declaredTags = options.tags ?? [];
265
+ const declaredTagNames = new Set(declaredTags.map((t)=>t.name));
266
+ const tags = [
267
+ ...declaredTags,
268
+ ...collectedTagNames.filter((name)=>!declaredTagNames.has(name)).map((name)=>({
269
+ name
270
+ }))
271
+ ];
215
272
  const document = {
216
273
  openapi: '3.1.0',
217
274
  info: {
@@ -229,5 +286,11 @@ export function createOpenApiDocument(rootModule, options = {}) {
229
286
  schemas
230
287
  };
231
288
  }
289
+ // Only attach `tags` when non-empty. Emitting `tags: []` on a tag-less
290
+ // spec is the exact symptom the consumer reported, so omit the key
291
+ // entirely in that case (mirrors the `components` handling above).
292
+ if (tags.length > 0) {
293
+ document.tags = tags;
294
+ }
232
295
  return document;
233
296
  }
@@ -90,6 +90,17 @@ export interface ApiResponseEntry extends ApiResponseOptions {
90
90
  export interface CreateOpenApiDocumentOptions {
91
91
  info?: Partial<OpenApiInfo>;
92
92
  globalPrefix?: string;
93
+ /**
94
+ * Declare top-level tag groups with descriptions and an explicit order
95
+ * (mirrors NestJS `DocumentBuilder().addTag(name, description)`). Tags
96
+ * actually used by operations but NOT declared here are still emitted —
97
+ * appended after the declared ones in first-seen order. Declared tags
98
+ * with no operations are kept (lets you pre-declare ordering/description).
99
+ */
100
+ tags?: Array<{
101
+ name: string;
102
+ description?: string;
103
+ }>;
93
104
  }
94
105
  export interface MountOpenApiOptions {
95
106
  /** Pre-built OpenAPI document to serve. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velajs/vela",
3
- "version": "1.8.2",
3
+ "version": "1.8.4",
4
4
  "description": "NestJS-compatible framework for edge runtimes, powered by Hono",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",