@hulla/api-node 0.0.0-stage → 2.0.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/http.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { AdapterErrorInput, AdapterResponse, AdapterRuntimeOptions } from "@hulla/api/adapters";
2
+ import { Awaitable, ServerAdapter, ServerContextInput, ServerExecutableFor } from "@hulla/api/server";
3
+ import { IncomingMessage, ServerResponse } from "node:http";
4
+ import { Contract } from "@hulla/api";
5
+ //#region src/http.d.ts
6
+ type NodeHttpRequest = IncomingMessage;
7
+ type NodeHttpResponse = ServerResponse<NodeHttpRequest>;
8
+ type NodeHttpHandler = (request: NodeHttpRequest, response: NodeHttpResponse) => void;
9
+ type NodeHttpAdapterContext = {
10
+ readonly request: NodeHttpRequest;
11
+ readonly response: NodeHttpResponse;
12
+ };
13
+ type NodeHttpContextInput<ContractType extends Contract = Contract> = ServerContextInput<ContractType> & NodeHttpAdapterContext;
14
+ type NodeHttpServerErrorInput = Omit<AdapterErrorInput, 'hostContext' | 'request'> & NodeHttpAdapterContext;
15
+ type NodeHttpServerOptions = Omit<AdapterRuntimeOptions, 'onError'> & {
16
+ /** Optional byte limit for adapter-owned body reads. Defaults to Infinity; host limits still apply. */
17
+ readonly maxBodyBytes?: number;
18
+ readonly onError?: ((input: NodeHttpServerErrorInput) => Awaitable<AdapterResponse | undefined | void>) | undefined;
19
+ };
20
+ type NodeHttpAdapter = ServerAdapter<'node-http', NodeHttpAdapterContext> & {
21
+ readonly mount: <const ContractType extends Contract, const Context extends object>(implementation: ServerExecutableFor<ContractType, Context, 'node-http', NodeHttpAdapterContext>, options?: NodeHttpServerOptions) => NodeHttpHandler;
22
+ };
23
+ /** Creates a dependency-free Node.js HTTP adapter for one server implementation or fragment. */
24
+ declare function nodeHttpAdapter(options?: NodeHttpServerOptions): NodeHttpAdapter;
25
+ //#endregion
26
+ export { NodeHttpAdapter, NodeHttpAdapterContext, NodeHttpContextInput, NodeHttpHandler, NodeHttpRequest, NodeHttpResponse, NodeHttpServerErrorInput, NodeHttpServerOptions, nodeHttpAdapter };
package/dist/http.js ADDED
@@ -0,0 +1,107 @@
1
+ import { createNodeBodyReader, nodeRequestHeader, nodeRequestHeaders, nodeRequestLifetime, writeNodeResponse } from "./index.js";
2
+ import { bodyLimit, createAdapterHandler, errorResponse } from "@hulla/api/adapters";
3
+ import { assertAdapterContext, createServerAdapter, serverContextAdapterId } from "@hulla/api/server";
4
+
5
+ //#region src/http.ts
6
+ function readHeader(name) {
7
+ return nodeRequestHeader(this.request.headers, name);
8
+ }
9
+ function pathname(url) {
10
+ const authority = url.indexOf("://");
11
+ const start = authority === -1 ? 0 : url.indexOf("/", authority + 3);
12
+ if (start === -1) return "/";
13
+ const query = url.indexOf("?", start);
14
+ const hash = url.indexOf("#", start);
15
+ const end = query === -1 ? hash === -1 ? url.length : hash : hash === -1 ? query : Math.min(query, hash);
16
+ return url.slice(start, end) || "/";
17
+ }
18
+ function requestQuery(url) {
19
+ const query = url.indexOf("?");
20
+ if (query === -1) return void 0;
21
+ const hash = url.indexOf("#", query + 1);
22
+ return new URLSearchParams(url.slice(query + 1, hash === -1 ? void 0 : hash));
23
+ }
24
+ function transportFailure(_error, response) {
25
+ if (response.destroyed || response.writableEnded) return;
26
+ if (response.headersSent) {
27
+ response.destroy();
28
+ return;
29
+ }
30
+ response.statusCode = 500;
31
+ response.setHeader("content-type", "text/plain; charset=utf-8");
32
+ response.end("Internal Server Error");
33
+ }
34
+ function createNodeHttpHandler(implementation, options = {}) {
35
+ assertAdapterContext(implementation.context, "node-http");
36
+ const limit = bodyLimit(options.maxBodyBytes);
37
+ const usesNativeContext = serverContextAdapterId(implementation.context) !== void 0;
38
+ const dispatch = createAdapterHandler(implementation, options.onError === void 0 ? {} : { onError: ({ hostContext, ...input }) => options.onError?.({
39
+ ...input,
40
+ ...hostContext
41
+ }) });
42
+ return (request, response) => {
43
+ const lifetime = nodeRequestLifetime(request, response);
44
+ const method = request.method?.toUpperCase() ?? "";
45
+ const dispatchMethod = method === "HEAD" ? "GET" : method;
46
+ const url = request.url ?? "/";
47
+ const query = requestQuery(url);
48
+ const nativeContext = {
49
+ request,
50
+ response
51
+ };
52
+ const execute = async () => {
53
+ const result = await dispatch({
54
+ request,
55
+ signal: lifetime.signal,
56
+ method: dispatchMethod,
57
+ pathname: pathname(url),
58
+ ...query === void 0 ? {} : { query },
59
+ readHeaders: () => nodeRequestHeaders(request.headers),
60
+ readHeader,
61
+ readBody: createNodeBodyReader(request, limit),
62
+ ...usesNativeContext ? { contextInput: nativeContext } : {},
63
+ ...options.onError === void 0 ? {} : { hostContext: nativeContext }
64
+ });
65
+ await writeNodeResponse(result, response, method);
66
+ };
67
+ execute().catch(async (error) => {
68
+ const fallback = errorResponse(error, "transport");
69
+ let replacement;
70
+ try {
71
+ replacement = await options.onError?.({
72
+ error,
73
+ phase: "transport",
74
+ request,
75
+ response,
76
+ defaultResponse: fallback
77
+ });
78
+ } catch {}
79
+ if (response.headersSent) {
80
+ response.destroy();
81
+ return;
82
+ }
83
+ for (const name of response.getHeaderNames()) response.removeHeader(name);
84
+ try {
85
+ await writeNodeResponse(replacement ?? fallback, response, method);
86
+ } catch (failure) {
87
+ transportFailure(failure, response);
88
+ }
89
+ }).finally(lifetime.dispose);
90
+ };
91
+ }
92
+ let nodeHttpAdapterValue;
93
+ function createNodeHttpAdapter(defaults) {
94
+ return createServerAdapter("node-http", { mount: (implementation, options) => createNodeHttpHandler(implementation, options === void 0 ? defaults : {
95
+ ...defaults,
96
+ ...options
97
+ }) });
98
+ }
99
+ /** Creates a dependency-free Node.js HTTP adapter for one server implementation or fragment. */
100
+ function nodeHttpAdapter(options) {
101
+ if (options !== void 0) return createNodeHttpAdapter({ ...options });
102
+ nodeHttpAdapterValue ??= createNodeHttpAdapter({});
103
+ return nodeHttpAdapterValue;
104
+ }
105
+
106
+ //#endregion
107
+ export { nodeHttpAdapter };
@@ -0,0 +1,16 @@
1
+ import { AdapterResponse } from "@hulla/api/adapters";
2
+ import { IncomingHttpHeaders, IncomingMessage, ServerResponse } from "node:http";
3
+ //#region src/index.d.ts
4
+ /** Reads one normalized Node header without copying unrelated fields. */
5
+ declare function nodeRequestHeader(source: IncomingHttpHeaders, name: string): string | undefined;
6
+ declare function nodeRequestHeaders(source: IncomingHttpHeaders): Readonly<Record<string, string>>;
7
+ declare function createNodeBodyReader(request: IncomingMessage, limit: number, host?: string): (representation: string) => Promise<unknown>;
8
+ /** Request lifetime shared by Node HTTP and Express; never retained after response completion. */
9
+ declare function nodeRequestLifetime(request: IncomingMessage, response: ServerResponse): {
10
+ signal: AbortSignal;
11
+ dispose: () => void;
12
+ };
13
+ /** Native response writing; callers retain their host registration and error policy. */
14
+ declare function writeNodeResponse(source: AdapterResponse, target: ServerResponse, method: string): Promise<void>;
15
+ //#endregion
16
+ export { createNodeBodyReader, nodeRequestHeader, nodeRequestHeaders, nodeRequestLifetime, writeNodeResponse };
package/dist/index.js ADDED
@@ -0,0 +1,197 @@
1
+ import { readBodyBytes, toFetchHeaders } from "@hulla/api/adapters";
2
+
3
+ //#region src/index.ts
4
+ /** Reads one normalized Node header without copying unrelated fields. */
5
+ function nodeRequestHeader(source, name) {
6
+ if (name.startsWith(":") || !Object.hasOwn(source, name)) return void 0;
7
+ const value = source[name];
8
+ return typeof value === "string" ? value : value?.join(", ");
9
+ }
10
+ function nodeRequestHeaders(source) {
11
+ const entries = Object.entries(source);
12
+ let length = 0;
13
+ for (const entry of entries) {
14
+ const [name, value] = entry;
15
+ if (value === void 0 || name.startsWith(":")) continue;
16
+ entry[1] = typeof value === "string" ? value : value.join(", ");
17
+ entries[length++] = entry;
18
+ }
19
+ entries.length = length;
20
+ return Object.fromEntries(entries);
21
+ }
22
+ function createNodeBodyReader(request, limit, host = "Node HTTP") {
23
+ let body;
24
+ return async (representation) => {
25
+ const bytes = await (body ??= readBodyBytes(request.iterator({ destroyOnReturn: false }), limit).catch((error) => {
26
+ request.resume();
27
+ throw error;
28
+ }));
29
+ switch (representation) {
30
+ case "json": return JSON.parse(new TextDecoder().decode(bytes));
31
+ case "text": return new TextDecoder().decode(bytes);
32
+ case "bytes": return bytes;
33
+ case "form-data": {
34
+ const contentType = request.headers["content-type"];
35
+ return new Response(bytes, { ...contentType === void 0 ? {} : { headers: { "content-type": contentType } } }).formData();
36
+ }
37
+ default: throw new TypeError(`Unsupported ${host} request body representation ${representation}`);
38
+ }
39
+ };
40
+ }
41
+ /** Request lifetime shared by Node HTTP and Express; never retained after response completion. */
42
+ function nodeRequestLifetime(request, response) {
43
+ const controller = new AbortController();
44
+ const abort = () => controller.abort(/* @__PURE__ */ new Error("HTTP connection closed"));
45
+ const close = () => {
46
+ if (!response.writableEnded) abort();
47
+ dispose();
48
+ };
49
+ const dispose = () => {
50
+ request.off("aborted", abort);
51
+ response.off("close", close);
52
+ response.off("finish", dispose);
53
+ };
54
+ request.once("aborted", abort);
55
+ response.once("close", close);
56
+ response.once("finish", dispose);
57
+ if (request.destroyed && !request.complete) abort();
58
+ return {
59
+ signal: controller.signal,
60
+ dispose
61
+ };
62
+ }
63
+ function drain(response) {
64
+ if (response.destroyed) return Promise.reject(/* @__PURE__ */ new Error("HTTP response closed"));
65
+ return new Promise((resolve, reject) => {
66
+ const cleanup = () => {
67
+ response.off("drain", ready);
68
+ response.off("close", closed);
69
+ response.off("error", failed);
70
+ };
71
+ const ready = () => {
72
+ cleanup();
73
+ resolve();
74
+ };
75
+ const closed = () => failed(/* @__PURE__ */ new Error("HTTP response closed before drain"));
76
+ const failed = (error) => {
77
+ cleanup();
78
+ reject(error);
79
+ };
80
+ response.once("drain", ready);
81
+ response.once("close", closed);
82
+ response.once("error", failed);
83
+ });
84
+ }
85
+ async function writeStream(source, target) {
86
+ const iterator = Symbol.asyncIterator in source ? source[Symbol.asyncIterator]() : source[Symbol.iterator]();
87
+ let finished = false;
88
+ let returning;
89
+ const closeSource = async () => {
90
+ if (finished) return;
91
+ returning ??= Promise.resolve().then(() => iterator.return?.());
92
+ await returning;
93
+ };
94
+ const disconnected = () => {
95
+ closeSource().catch(() => {});
96
+ };
97
+ target.once("close", disconnected);
98
+ try {
99
+ while (!target.destroyed && !target.writableEnded) {
100
+ const item = await iterator.next();
101
+ if (item.done) {
102
+ finished = true;
103
+ break;
104
+ }
105
+ if (target.destroyed || target.writableEnded) break;
106
+ if (!(item.value instanceof Uint8Array)) throw new TypeError("Stream chunk must be Uint8Array");
107
+ if (!target.write(item.value)) await drain(target);
108
+ }
109
+ if (!target.destroyed && !target.writableEnded) target.end();
110
+ } finally {
111
+ target.off("close", disconnected);
112
+ await closeSource();
113
+ }
114
+ }
115
+ async function writeFetch(source, target, method) {
116
+ target.statusCode = source.status;
117
+ source.headers.forEach((value, name) => {
118
+ if (name !== "set-cookie") target.setHeader(name, value);
119
+ });
120
+ const cookies = source.headers.getSetCookie();
121
+ if (cookies.length > 0) target.setHeader("set-cookie", cookies);
122
+ if (source.body === null) {
123
+ target.end();
124
+ return;
125
+ }
126
+ if (method === "HEAD") {
127
+ await source.body.cancel();
128
+ target.end();
129
+ return;
130
+ }
131
+ const reader = source.body.getReader();
132
+ const close = () => {
133
+ reader.cancel().catch(() => {});
134
+ };
135
+ target.once("close", close);
136
+ try {
137
+ async function* chunks() {
138
+ try {
139
+ while (true) {
140
+ const item = await reader.read();
141
+ if (item.done) return;
142
+ yield item.value;
143
+ }
144
+ } finally {
145
+ await reader.cancel();
146
+ }
147
+ }
148
+ await writeStream(chunks(), target);
149
+ } finally {
150
+ target.off("close", close);
151
+ reader.releaseLock();
152
+ }
153
+ }
154
+ /** Native response writing; callers retain their host registration and error policy. */
155
+ async function writeNodeResponse(source, target, method) {
156
+ const body = source.body;
157
+ if (body.kind === "raw") {
158
+ if (!(body.value instanceof Response) || body.value.status !== source.status) throw new TypeError("Raw Fetch response status must match its declared contract status");
159
+ return writeFetch(body.value, target, method);
160
+ }
161
+ if (body.kind === "form-data") {
162
+ if (!(body.value instanceof FormData)) throw new TypeError("Form data response body must be FormData");
163
+ return writeFetch(new Response(body.value, {
164
+ status: source.status,
165
+ headers: toFetchHeaders(source.headers)
166
+ }), target, method);
167
+ }
168
+ const encoded = body.kind === "json" ? JSON.stringify(body.value) : void 0;
169
+ if (body.kind === "json" && encoded === void 0) throw new TypeError("JSON response body cannot encode to undefined");
170
+ target.statusCode = source.status;
171
+ for (const [name, value] of Object.entries(source.headers)) target.setHeader(name, value);
172
+ if (method === "HEAD" || body.kind === "empty") {
173
+ if (body.kind === "stream") {
174
+ const stream = body.value;
175
+ await (stream[Symbol.asyncIterator]?.() ?? stream[Symbol.iterator]()).return?.();
176
+ }
177
+ target.end();
178
+ return;
179
+ }
180
+ switch (body.kind) {
181
+ case "json":
182
+ target.end(encoded);
183
+ return;
184
+ case "text":
185
+ if (typeof body.value !== "string") throw new TypeError("Text response body must be a string");
186
+ target.end(body.value);
187
+ return;
188
+ case "bytes":
189
+ if (!(body.value instanceof Uint8Array)) throw new TypeError("Byte response body must be Uint8Array");
190
+ target.end(body.value);
191
+ return;
192
+ case "stream": return writeStream(body.value, target);
193
+ }
194
+ }
195
+
196
+ //#endregion
197
+ export { createNodeBodyReader, nodeRequestHeader, nodeRequestHeaders, nodeRequestLifetime, writeNodeResponse };
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "@hulla/api-node",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2.0.0",
4
+ "description": "Node.js HTTP adapter and shared native HTTP helpers for @hulla/api",
5
+ "license": "MIT",
6
+ "files": [
7
+ "dist"
8
+ ],
9
+ "type": "module",
10
+ "sideEffects": false,
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js"
15
+ },
16
+ "./http": {
17
+ "types": "./dist/http.d.ts",
18
+ "import": "./dist/http.js"
19
+ }
20
+ },
21
+ "scripts": {
22
+ "build": "tsdown",
23
+ "clean": "rm -rf dist coverage",
24
+ "test": "vitest run",
25
+ "test:coverage": "vitest run --coverage",
26
+ "test:watch": "vitest",
27
+ "typecheck": "tsc --noEmit"
28
+ },
29
+ "devDependencies": {
30
+ "@hulla/api": "2.0.0",
31
+ "@types/node": "^26.2.0",
32
+ "zod": "^4.4.3"
33
+ },
34
+ "peerDependencies": {
35
+ "@hulla/api": "^2.0.0",
36
+ "@types/node": ">=22.0.0"
37
+ }
38
+ }
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.