@hulla/api-nestjs 0.0.0-stage → 2.0.1

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,43 @@
1
+ import "reflect-metadata";
2
+ import { CompiledContractRoute, Contract } from "@hulla/api";
3
+ import { AdapterErrorInput, AdapterResponse } from "@hulla/api/adapters";
4
+ import { Awaitable, ServerAdapter, ServerContextInput, ServerExecutableFor } from "@hulla/api/server";
5
+ import { DynamicModule, InjectionToken, ModuleMetadata, Provider, Scope, Type } from "@nestjs/common";
6
+ import { IncomingHttpHeaders, IncomingMessage } from "node:http";
7
+ //#region src/index.d.ts
8
+ /** Common Express/Fastify request fields. Pass native request/response types to nestAdapter for host-specific fields. */
9
+ type NestRequest = {
10
+ readonly method: string;
11
+ readonly url: string;
12
+ readonly headers: IncomingHttpHeaders;
13
+ readonly params?: Readonly<Record<string, string>>;
14
+ readonly body?: unknown;
15
+ readonly raw?: IncomingMessage;
16
+ };
17
+ type NestAdapterContext<Request extends NestRequest = NestRequest, Response = unknown> = {
18
+ readonly request: Request;
19
+ readonly response: Response;
20
+ };
21
+ type NestContextInput<ContractType extends Contract = Contract, Request extends NestRequest = NestRequest, Response = unknown> = ServerContextInput<ContractType> & NestAdapterContext<Request, Response>;
22
+ type NestServerErrorInput<Request extends NestRequest = NestRequest, Response = unknown> = Omit<AdapterErrorInput, 'request' | 'hostContext'> & NestAdapterContext<Request, Response>;
23
+ type NestServerOptions<Request extends NestRequest = NestRequest, Response = unknown> = {
24
+ readonly onError?: (input: NestServerErrorInput<Request, Response>) => Awaitable<AdapterResponse | undefined | void>;
25
+ readonly controllerDecorators?: readonly ClassDecorator[];
26
+ readonly routeDecorators?: (route: Pick<CompiledContractRoute, 'key' | 'method' | 'path'>) => readonly MethodDecorator[];
27
+ };
28
+ type NestFactoryOptions<ContractType extends Contract, Context extends object, Dependencies extends unknown[], Request extends NestRequest, Response> = NestServerOptions<Request, Response> & {
29
+ readonly imports?: ModuleMetadata['imports'];
30
+ readonly providers?: Provider[];
31
+ readonly inject: { readonly [Key in keyof Dependencies]: InjectionToken<Dependencies[Key]>; };
32
+ readonly useFactory: (...dependencies: Dependencies) => Awaitable<ServerExecutableFor<ContractType, Context, 'nestjs', NestAdapterContext<Request, Response>>>;
33
+ readonly scope?: Scope;
34
+ };
35
+ type NestAdapter<Request extends NestRequest = NestRequest, Response = unknown> = ServerAdapter<'nestjs', NestAdapterContext<Request, Response>> & {
36
+ /** Returns a native controller for the application's @Module({ controllers: [...] }). Supports fragments. */
37
+ readonly mount: <const ContractType extends Contract, const Context extends object>(implementation: ServerExecutableFor<ContractType, Context, 'nestjs', NestAdapterContext<Request, Response>>, options?: NestServerOptions<Request, Response>) => Type<object>;
38
+ /** Creates an injectable implementation provider and native controller, including request-scoped dependencies. */
39
+ readonly register: <const ContractType extends Contract, const Context extends object, Dependencies extends unknown[]>(contract: ContractType, options: NestFactoryOptions<ContractType, Context, Dependencies, Request, Response>) => DynamicModule;
40
+ };
41
+ declare function nestAdapter<Request extends NestRequest = NestRequest, Response = unknown>(defaults?: NestServerOptions<Request, Response>): NestAdapter<Request, Response>;
42
+ //#endregion
43
+ export { NestAdapter, NestAdapterContext, NestContextInput, NestFactoryOptions, NestRequest, NestServerErrorInput, NestServerOptions, nestAdapter };
package/dist/index.js ADDED
@@ -0,0 +1,184 @@
1
+ import "reflect-metadata";
2
+ import { Readable } from "node:stream";
3
+ import { compileContract } from "@hulla/api";
4
+ import { nodeRequestLifetime } from "@hulla/api-node";
5
+ import { createAdapterRuntime, errorResponse, toFetchResponse } from "@hulla/api/adapters";
6
+ import { assertAdapterContext, createServerAdapter } from "@hulla/api/server";
7
+ import { Controller, Inject, Module, Req, RequestMapping, RequestMethod, Res, StreamableFile } from "@nestjs/common";
8
+ import { HttpAdapterHost } from "@nestjs/core";
9
+
10
+ //#region src/index.ts
11
+ const routeKey = (route) => JSON.stringify(route.key);
12
+ function runtime(implementation, options) {
13
+ assertAdapterContext(implementation.context, "nestjs");
14
+ return createAdapterRuntime(implementation, options.onError === void 0 ? {} : { onError: ({ hostContext, ...input }) => options.onError({
15
+ ...input,
16
+ ...hostContext
17
+ }) });
18
+ }
19
+ function nodeResponse(response) {
20
+ return response.raw ?? response;
21
+ }
22
+ function readable(source, response, onError) {
23
+ const stream = Readable.from(source, { objectMode: false });
24
+ const disconnected = () => stream.destroy();
25
+ response.once("close", disconnected);
26
+ stream.once("close", () => response.off("close", disconnected));
27
+ stream.once("error", (error) => {
28
+ onError(error);
29
+ response.destroy();
30
+ });
31
+ return stream;
32
+ }
33
+ async function output(result, host, request, response, observe) {
34
+ const native = host.httpAdapter;
35
+ let body = result.body;
36
+ let headers = result.headers;
37
+ if (body.kind === "raw" || body.kind === "form-data") {
38
+ const web = toFetchResponse(result);
39
+ const values = Object.fromEntries(web.headers);
40
+ const cookies = web.headers.getSetCookie();
41
+ if (cookies.length > 0) values["set-cookie"] = cookies;
42
+ headers = values;
43
+ body = web.body === null ? {
44
+ kind: "empty",
45
+ value: void 0
46
+ } : {
47
+ kind: "stream",
48
+ value: (async function* () {
49
+ const reader = web.body.getReader();
50
+ try {
51
+ while (true) {
52
+ const item = await reader.read();
53
+ if (item.done) return;
54
+ yield item.value;
55
+ }
56
+ } finally {
57
+ await reader.cancel().catch(() => {});
58
+ reader.releaseLock();
59
+ }
60
+ })()
61
+ };
62
+ if (request.method === "HEAD") await web.body?.cancel();
63
+ }
64
+ native.status(response, result.status);
65
+ for (const [name, value] of Object.entries(headers)) if (typeof value === "string") native.setHeader(response, name, value);
66
+ else for (const field of value) native.appendHeader(response, name, field);
67
+ if (request.method === "HEAD") {
68
+ if (body.kind === "stream") await body.value[Symbol.asyncIterator]().return?.();
69
+ return;
70
+ }
71
+ if (body.kind === "empty") return void 0;
72
+ if (body.kind === "stream") {
73
+ const type = headers["content-type"];
74
+ return new StreamableFile(readable(body.value, nodeResponse(response), observe), typeof type === "string" ? { type } : {});
75
+ }
76
+ if (body.kind === "bytes") return new StreamableFile(body.value);
77
+ return body.value;
78
+ }
79
+ function controller(routes, options, configured, token) {
80
+ for (const route of routes) if (route.method === "QUERY") throw new TypeError("NestJS integration does not support QUERY routes across its supported host versions");
81
+ class ContractController {
82
+ host;
83
+ routes;
84
+ constructor(host, injected) {
85
+ this.host = host;
86
+ if (!["express", "fastify"].includes(host.httpAdapter.getType())) throw new TypeError("NestJS integration requires the Express or Fastify platform adapter");
87
+ const runtime = injected ?? configured;
88
+ this.routes = new Map(runtime.routes.map((route) => [routeKey(route), route]));
89
+ if (this.routes.size !== routes.length || routes.some((route) => !this.routes.has(routeKey(route)))) throw new TypeError("NestJS factory must implement every registered contract route");
90
+ }
91
+ }
92
+ Controller()(ContractController);
93
+ Inject(HttpAdapterHost)(ContractController, void 0, 0);
94
+ if (token) Inject(token)(ContractController, void 0, 1);
95
+ for (const [index, route] of routes.entries()) {
96
+ const name = `operation${index}`;
97
+ Object.defineProperty(ContractController.prototype, name, {
98
+ configurable: true,
99
+ value: async function(request, response) {
100
+ const raw = request.raw ?? request;
101
+ const lifetime = nodeRequestLifetime(raw, nodeResponse(response));
102
+ const nativeContext = {
103
+ request,
104
+ response
105
+ };
106
+ const headers = {};
107
+ for (const [name, value] of Object.entries(request.headers)) if (value !== void 0) headers[name] = typeof value === "string" ? value : value.join(", ");
108
+ const query = request.url.indexOf("?");
109
+ return output(await this.routes.get(routeKey(route)).execute({
110
+ request,
111
+ signal: lifetime.signal,
112
+ contextInput: nativeContext,
113
+ hostContext: nativeContext,
114
+ params: request.params ?? {},
115
+ headers,
116
+ ...query < 0 ? {} : { query: new URLSearchParams(request.url.slice(query + 1)) },
117
+ ...request.body === void 0 ? {} : { body: { value: request.body } },
118
+ readBody: async () => {
119
+ throw new TypeError("NestJS body is unavailable; configure the matching host parser before mounting");
120
+ }
121
+ }), this.host, request, response, (error) => {
122
+ Promise.resolve().then(() => options.onError?.({
123
+ ...nativeContext,
124
+ error,
125
+ phase: "transport",
126
+ route,
127
+ defaultResponse: errorResponse(error, "transport")
128
+ })).catch(() => {});
129
+ });
130
+ }
131
+ });
132
+ const descriptor = Object.getOwnPropertyDescriptor(ContractController.prototype, name);
133
+ RequestMapping({
134
+ path: route.path,
135
+ method: RequestMethod[route.method]
136
+ })(ContractController.prototype, name, descriptor);
137
+ Req()(ContractController.prototype, name, 0);
138
+ Res({ passthrough: true })(ContractController.prototype, name, 1);
139
+ for (const decorate of options.routeDecorators?.(route) ?? []) decorate(ContractController.prototype, name, descriptor);
140
+ }
141
+ for (const decorate of options.controllerDecorators ?? []) decorate(ContractController);
142
+ return ContractController;
143
+ }
144
+ function nestAdapter(defaults = {}) {
145
+ const configured = { ...defaults };
146
+ return createServerAdapter("nestjs", {
147
+ mount: (implementation, options) => {
148
+ const merged = {
149
+ ...configured,
150
+ ...options
151
+ };
152
+ const compiled = runtime(implementation, merged);
153
+ return controller(compiled.routes, merged, compiled);
154
+ },
155
+ register: (contract, options) => {
156
+ const merged = {
157
+ ...configured,
158
+ ...options
159
+ };
160
+ const token = Symbol("contract runtime");
161
+ const generated = controller(compileContract(contract).routes, merged, void 0, token);
162
+ class ContractModule {}
163
+ Module({})(ContractModule);
164
+ return {
165
+ module: ContractModule,
166
+ controllers: [generated],
167
+ imports: options.imports ?? [],
168
+ providers: [...options.providers ?? [], {
169
+ provide: token,
170
+ inject: [...options.inject],
171
+ ...options.scope === void 0 ? {} : { scope: options.scope },
172
+ useFactory: async (...dependencies) => {
173
+ const implementation = await options.useFactory(...dependencies);
174
+ if (implementation.contract !== contract) throw new TypeError("NestJS implementation factory returned a different contract");
175
+ return runtime(implementation, merged);
176
+ }
177
+ }]
178
+ };
179
+ }
180
+ });
181
+ }
182
+
183
+ //#endregion
184
+ export { nestAdapter };
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "@hulla/api-nestjs",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2.0.1",
4
+ "description": "Native NestJS controller and dependency-injection integration for @hulla/api",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/hulladev/api.git",
9
+ "directory": "packages/nestjs"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "type": "module",
15
+ "sideEffects": false,
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js"
20
+ }
21
+ },
22
+ "publishConfig": {
23
+ "access": "public",
24
+ "registry": "https://registry.npmjs.org/"
25
+ },
26
+ "scripts": {
27
+ "build": "tsdown",
28
+ "clean": "rm -rf dist coverage",
29
+ "test": "vitest run",
30
+ "test:coverage": "vitest run --coverage",
31
+ "test:watch": "vitest",
32
+ "typecheck": "tsc --noEmit"
33
+ },
34
+ "dependencies": {
35
+ "@hulla/api-node": "^2.0.1"
36
+ },
37
+ "devDependencies": {
38
+ "@hulla/api": "2.0.1",
39
+ "@nestjs/common": "^12.0.1",
40
+ "@nestjs/core": "^12.0.1",
41
+ "@nestjs/platform-express": "^12.0.1",
42
+ "@nestjs/platform-fastify": "^12.0.1",
43
+ "@types/express": "^5.0.6",
44
+ "@types/node": "^26.2.0",
45
+ "express": "^5.2.1",
46
+ "reflect-metadata": "^0.2.2",
47
+ "rxjs": "^7.8.2"
48
+ },
49
+ "peerDependencies": {
50
+ "@hulla/api": "^2.0.1",
51
+ "@nestjs/common": "^11.1.0 || ^12.0.1",
52
+ "@nestjs/core": "^11.1.0 || ^12.0.1",
53
+ "@types/node": ">=22.0.0",
54
+ "reflect-metadata": "^0.2.2",
55
+ "rxjs": "^7.8.2"
56
+ }
57
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.