@hile/http-over-micro 1.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.
package/AI.md ADDED
@@ -0,0 +1,350 @@
1
+ # AI Guide For @hile/http-over-micro
2
+
3
+
4
+
5
+ <!-- Generated by scripts/build-ai-context.mjs from docs/ai. Do not edit by hand. -->
6
+
7
+
8
+
9
+ Purpose: Preserve HTTP method, headers, status, JSON bodies, and binary streams across Registry-discovered Hile Micro calls.
10
+
11
+
12
+
13
+ Use this file when an AI agent installs the npm package and needs package-local examples, package selection rules, boundaries, and verification steps.
14
+
15
+
16
+
17
+ ## Package Selection
18
+
19
+
20
+
21
+ | User asks for | Use | Also read |
22
+ |---|---|---|
23
+ | Preserve HTTP semantics across a Micro call | `@hile/http-over-micro` | `packages/http-over-micro.md`, `packages/messaging-micro.md` |
24
+
25
+
26
+
27
+ # HTTP Over Micro
28
+
29
+ Package: `@hile/http-over-micro`.
30
+
31
+ ## Use When
32
+
33
+ Use this package when one HTTP ingress must project a request to a Registry-discovered Hile microservice while preserving HTTP method, duplicate headers, query values, status, cookies, redirects, JSON bodies, and streamed file bodies.
34
+
35
+ The package defines the transport contract only. The public listener and its authentication, authorization, route selection, header policy, and final Koa response remain owned by the gateway.
36
+
37
+ ## Do Not Use When
38
+
39
+ - Do not use it for ordinary in-process HTTP controllers that do not cross a Micro boundary.
40
+ - Do not use it as a durable queue or retry layer.
41
+ - Do not use it for RSC Flight, MCP, WebSocket upgrades, HTTP trailers, or informational `1xx` responses; those protocols keep their own transports.
42
+ - Do not put business logic in the gateway merely because the gateway performs the HTTP projection.
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pnpm add @hile/http-over-micro @hile/micro zod
48
+ ```
49
+
50
+ ## Imports
51
+
52
+ ```ts
53
+ import {
54
+ callHttpOverMicro,
55
+ defineHttpOverMicroMessage,
56
+ } from '@hile/http-over-micro'
57
+ import { z } from 'zod'
58
+ ```
59
+
60
+ ## Copy-Paste Example
61
+
62
+ Provider message:
63
+
64
+ ```ts
65
+ // src/messages/posts/[slug].msg.ts
66
+ import { defineHttpOverMicroMessage } from '@hile/http-over-micro'
67
+ import { z } from 'zod'
68
+
69
+ export default defineHttpOverMicroMessage({
70
+ method: 'POST',
71
+ schema: {
72
+ headers: z.object({ authorization: z.string().startsWith('Bearer ') }),
73
+ query: z.object({ draft: z.enum(['true', 'false']).default('false') }),
74
+ params: z.object({ slug: z.string().min(1) }),
75
+ body: z.object({ title: z.string().min(1), content: z.string() }),
76
+ },
77
+ }, async ({ request, params, invocation }) => {
78
+ // Call a model here; the example only shows the transport result.
79
+ return {
80
+ status: 201,
81
+ headers: {
82
+ location: `/posts/${params.slug}`,
83
+ 'set-cookie': ['flash=created; Path=/; HttpOnly', 'draft=; Max-Age=0; Path=/'],
84
+ },
85
+ body: {
86
+ created: true,
87
+ draft: request.query.draft === 'true',
88
+ requestId: invocation.context.values.requestId,
89
+ },
90
+ }
91
+ })
92
+ ```
93
+
94
+ Gateway-side call:
95
+
96
+ ```ts
97
+ const response = await callHttpOverMicro(
98
+ app,
99
+ 'cn.zlooks.blog.server',
100
+ '/posts/hello',
101
+ {
102
+ method: ctx.method,
103
+ headers: ctx.headers,
104
+ query: Object.entries(ctx.query).flatMap(([name, value]) =>
105
+ Array.isArray(value) ? value.map(item => [name, item] as const) : [[name, String(value)] as const],
106
+ ),
107
+ body: ctx.request.body,
108
+ },
109
+ { context: executionContext },
110
+ )
111
+
112
+ ctx.status = response.status
113
+ for (const [name, value] of response.headers) ctx.append(name, value)
114
+ return response.body
115
+ ```
116
+
117
+ Pass only gateway-approved end-to-end request headers. The sample assumes a body parser has already produced `ctx.request.body`; use the untouched incoming `Readable` instead for raw uploads.
118
+
119
+ ## More Examples
120
+
121
+ ### Upload and download without a transport switch
122
+
123
+ The caller supplies a normal JSON value or an `AsyncIterable`, `Uint8Array`, or `ArrayBuffer`. `callHttpOverMicro()` snapshots JSON values into the request envelope and automatically moves binary or iterable bodies into Micro request input.
124
+
125
+ ```ts
126
+ import { createReadStream } from 'node:fs'
127
+
128
+ const response = await callHttpOverMicro(app, 'files.server', '/files', {
129
+ method: 'PUT',
130
+ headers: { 'content-type': 'application/octet-stream' },
131
+ body: createReadStream('/tmp/archive.tar'),
132
+ }, { context })
133
+
134
+ if (response.bodyKind === 'stream') {
135
+ for await (const chunk of response.body) consumeDownloadedChunk(chunk)
136
+ }
137
+ ```
138
+
139
+ Provider:
140
+
141
+ ```ts
142
+ import { Readable } from 'node:stream'
143
+
144
+ export default defineHttpOverMicroMessage({ method: ['GET', 'PUT'] }, async ({ request }) => {
145
+ if (request.method === 'PUT') {
146
+ if (!(request.body instanceof Readable)) {
147
+ return { status: 400, body: { error: 'stream required' } }
148
+ }
149
+ await saveUpload(request.body)
150
+ return { status: 204 }
151
+ }
152
+
153
+ return {
154
+ status: 200,
155
+ headers: { 'content-type': 'application/octet-stream' },
156
+ body: openDownloadStream(),
157
+ }
158
+ })
159
+ ```
160
+
161
+ Streaming request bodies are non-replayable. Leave retries unset or set `retries: 0`; an explicit nonzero value fails before dispatch.
162
+
163
+ ### Response metadata before response bytes
164
+
165
+ Every HTTP-over-Micro call uses one Micro response stream. Its first chunk is a response head; body chunks follow only when `body.kind` is `stream`:
166
+
167
+ ```ts
168
+ type RequestEnvelope = {
169
+ protocol: '@hile/http-over-micro'
170
+ version: 1
171
+ type: 'request'
172
+ method: string
173
+ headers: Array<[string, string]>
174
+ query: Array<[string, string]>
175
+ body: { kind: 'empty' } | { kind: 'inline'; value: unknown } | { kind: 'stream' }
176
+ }
177
+
178
+ type ResponseHead = {
179
+ protocol: '@hile/http-over-micro'
180
+ version: 1
181
+ type: 'response'
182
+ status: number
183
+ headers: Array<[string, string]>
184
+ body: { kind: 'empty' } | { kind: 'inline'; value: unknown } | { kind: 'stream' }
185
+ }
186
+ ```
187
+
188
+ Header tuples deliberately preserve order and duplicate fields such as `Set-Cookie`. Header names are canonical lowercase on the wire. Query tuples preserve repeated query values without inventing an object serialization rule.
189
+
190
+ ### Zod parses, not merely checks
191
+
192
+ `schema.headers`, `schema.query`, `schema.params`, and `schema.body` each receive the logical value seen by the handler. Schema transforms and coercions are returned to the handler.
193
+
194
+ For a streamed request, `schema.body` receives the `Readable`. Use a Zod custom or union schema only when that endpoint intentionally accepts a stream; a JSON-object body schema rejects the stream naturally.
195
+
196
+ ## Compose With
197
+
198
+ - Use `@hile/http` or `@hile/http-next` for the one public HTTP listener and file-system Controller.
199
+ - Use `@hile/micro` for Registry discovery, context propagation, cancellation, timeouts, and credit-based input/output flow control.
200
+ - Use `@hile/model` behind the provider handler for reusable business behavior.
201
+ - Use the gateway's own policy to select which headers and cookies may cross the boundary; this package preserves selected values but does not authorize them.
202
+
203
+ ## Runtime And Lifecycle Notes
204
+
205
+ - `defineHttpOverMicroMessage()` returns a normal `defineMicroMessage()` definition and is loaded or registered through the standard Micro message loader.
206
+ - The Micro route remains the file/message path. HTTP method is protocol data and one definition may accept one method or a method list. Unsupported methods return `405` plus `Allow` without invoking the handler.
207
+ - Inline bodies use JSON serialization semantics and default to a 1 MiB bound. Override `limits.maxInlineBodyBytes` explicitly on both ends when a deployment requires another limit; use streams for files and large byte bodies.
208
+ - Final response statuses are `200..599`. `HEAD`, `204`, `205`, and `304` responses reject bodies. `1xx`, protocol upgrades, and trailers are intentionally out of scope.
209
+ - Destroying the returned streamed body cancels the underlying Micro response. `signal`, total timeout, idle timeout, and stream window are passed to `Application.stream()`.
210
+ - Credit-based backpressure bounds buffered chunks, not the total number of transferred bytes. The HTTP ingress and provider handler must each enforce their endpoint-specific upload/download byte limit while consuming a stream.
211
+ - The caller owns the returned response stream and must consume or destroy it. The package fully consumes empty and inline responses before resolving.
212
+ - Cookies and `Location` are opaque response headers. The public gateway remains responsible for cookie ownership, security attributes, redirect policy, and hop-by-hop header removal.
213
+ - Treat `namespace` and `url` as routing authority. A public gateway must resolve them through its validated provider catalog or an equivalent allow policy; never dispatch an arbitrary client-supplied namespace directly.
214
+
215
+ ## Anti-Patterns
216
+
217
+ - Do not handcraft the request envelope or consume the response-head frame yourself; use `callHttpOverMicro()`.
218
+ - Do not call `Application.call()` for this protocol. A response may need headers followed by a body stream, so the package intentionally uses `Application.stream()` for every result.
219
+ - Do not encode files as Base64 inside an inline JSON body.
220
+ - Do not collapse response headers into a plain object when duplicate `Set-Cookie` values matter.
221
+ - Do not enable retries for an upload stream.
222
+ - Do not forward every inbound header, raw cookie, or identity credential merely because the protocol can carry it.
223
+
224
+ ## Verification Checklist
225
+
226
+ - Unit tests cover inline, empty, malformed, method-mismatch, and Zod-coercion cases.
227
+ - A real Registry-discovered WebSocket test carries request and response streams at the same time.
228
+ - Duplicate response headers survive in order.
229
+ - Invalid request metadata fails with HTTP status `400`; invalid upstream response metadata fails locally with `502`.
230
+ - Inline bodies are bounded and JSON-serializable; files use stream bodies.
231
+ - Cancellation, timeout, idle timeout, and backpressure remain owned by `@hile/micro` and `@hile/message-modem`.
232
+
233
+
234
+
235
+ # Related Recipes
236
+
237
+
238
+
239
+ # Micro RPC With Message Loader
240
+
241
+ ## Complete Example
242
+
243
+ Provider handler:
244
+
245
+ ```ts
246
+ // src/messages/charge.msg.ts
247
+ import { defineMicroMessage } from '@hile/micro'
248
+
249
+ export default defineMicroMessage(async ({ data, invocation }) => {
250
+ return { charged: true, input: data, requestId: invocation.context.values.requestId }
251
+ })
252
+ ```
253
+
254
+ Provider boot:
255
+
256
+ ```ts
257
+ // src/services/app.boot.ts
258
+ import { defineService } from '@hile/core'
259
+ import { Application } from '@hile/micro'
260
+
261
+ export default defineService('billing.micro', async (shutdown) => {
262
+ const app = new Application({
263
+ namespace: 'billing',
264
+ registry: { host: '127.0.0.1', port: 9876 },
265
+ advertiseHost: '127.0.0.1',
266
+ })
267
+
268
+ await app.load(new URL('../messages', import.meta.url).pathname)
269
+ const stop = await app.listen(9101)
270
+ shutdown(stop)
271
+ return app
272
+ })
273
+ ```
274
+
275
+ Consumer:
276
+
277
+ ```ts
278
+ import { randomUUID } from 'node:crypto'
279
+ import { createExecutionContext } from '@hile/context'
280
+
281
+ const context = createExecutionContext({ requestId: randomUUID(), tenantId: 't1' })
282
+ const result = await app.call('billing', '/charge', {
283
+ tenantId: 't1',
284
+ amount: 100,
285
+ }, { context })
286
+ ```
287
+
288
+ ## File Layout
289
+
290
+ ```text
291
+ provider/
292
+ src/messages/charge.msg.ts
293
+ src/services/app.boot.ts
294
+ consumer/
295
+ src/models/payments/pay.model.ts
296
+ ```
297
+
298
+ ## User Intent
299
+
300
+ Use this recipe when services communicate over Hile registry-backed RPC.
301
+
302
+ ## Packages To Use
303
+
304
+ - `@hile/micro`
305
+ - `@hile/context` for the required explicit execution context carrier
306
+ - `@hile/redis-idempotency` for retryable side effects
307
+
308
+ ## Implementation Steps
309
+
310
+ 1. Start a Registry with `hile registry`.
311
+ 2. Start providers with stable namespaces.
312
+ 3. Default-export `defineMicroMessage()` handlers and load them through `app.load()`.
313
+ 4. Create context at ingress and call providers with `await app.call(namespace, url, data, { context })`.
314
+ 5. Use `app.stream()` only for async-generator handlers.
315
+ 6. For streamed request bodies, consume `input` in the `defineMicroMessage()` handler and pass the source through `options.input`; pass the stream as `data` only when no structured metadata is needed.
316
+
317
+ ## Failure And Cleanup Behavior
318
+
319
+ - `Application.call()` may retry; side-effecting handlers need idempotency.
320
+ - A streamed request body is non-replayable. Its retry default is `0`, and an explicit nonzero retry count is rejected before discovery.
321
+ - Request input and response output have independent credit-based backpressure and may be active together.
322
+ - Registry disconnect triggers reconnect; apps re-declare topics and subscriptions.
323
+ - Circuit breaker excludes failing nodes for cooldown.
324
+
325
+ ## Verification Checklist
326
+
327
+ - Registry is reachable.
328
+ - Provider namespace matches consumer call.
329
+ - Handlers default-export `defineMicroMessage()` and consume explicit invocation context when needed.
330
+ - Consumer code awaits `app.call(..., { context })` directly.
331
+ - Streamed request handlers consume `input: Readable`, preserve structured metadata in `data`, and do not enable retries.
332
+
333
+
334
+
335
+ # Global Guardrails
336
+
337
+
338
+
339
+ ## Never Generate These Patterns
340
+
341
+ - Do not call `loadService()` at module top level; it starts resources during import.
342
+ - Do not default-export plain functions from `*.boot.*` files; `hile start` expects a Hile service.
343
+ - Do not set `ctx.body` and also return a controller value.
344
+ - Do not assume `@hile/http` Zod validation mutates or coerces `ctx.query`, `ctx.params`, or `ctx.request.body`.
345
+ - Do not put reusable business logic only in controllers, pages, queue workers, or message handlers.
346
+ - Do not use old message examples that append a secondary response getter; current request APIs return promises directly.
347
+ - Do not invent service-specific HTTP-in-Micro envelopes or Base64 file bodies; use `@hile/http-over-micro` and its request/response streams.
348
+ - Do not claim exactly-once delivery or execution from Redis locks, queues, idempotency, or rate limits.
349
+ - Do not use queue `jobId` as the only side-effect idempotency boundary.
350
+ - Do not log the entire async context by default.
package/README.md ADDED
@@ -0,0 +1,176 @@
1
+ # @hile/http-over-micro
2
+
3
+ <!-- Generated by scripts/build-ai-context.mjs from docs/ai. Do not edit by hand. -->
4
+
5
+ Preserve HTTP method, headers, status, JSON bodies, and binary streams across Registry-discovered Hile Micro calls.
6
+
7
+ This README is intentionally short and example-first. The complete AI-facing guide ships in `AI.md` in this package.
8
+
9
+ ## When To Use
10
+
11
+ Use this package when one HTTP ingress must project a request to a Registry-discovered Hile microservice while preserving HTTP method, duplicate headers, query values, status, cookies, redirects, JSON bodies, and streamed file bodies.
12
+
13
+ The package defines the transport contract only. The public listener and its authentication, authorization, route selection, header policy, and final Koa response remain owned by the gateway.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pnpm add @hile/http-over-micro @hile/micro zod
19
+ ```
20
+
21
+ ## Copy-Paste Example
22
+
23
+ Provider message:
24
+
25
+ ```ts
26
+ // src/messages/posts/[slug].msg.ts
27
+ import { defineHttpOverMicroMessage } from '@hile/http-over-micro'
28
+ import { z } from 'zod'
29
+
30
+ export default defineHttpOverMicroMessage({
31
+ method: 'POST',
32
+ schema: {
33
+ headers: z.object({ authorization: z.string().startsWith('Bearer ') }),
34
+ query: z.object({ draft: z.enum(['true', 'false']).default('false') }),
35
+ params: z.object({ slug: z.string().min(1) }),
36
+ body: z.object({ title: z.string().min(1), content: z.string() }),
37
+ },
38
+ }, async ({ request, params, invocation }) => {
39
+ // Call a model here; the example only shows the transport result.
40
+ return {
41
+ status: 201,
42
+ headers: {
43
+ location: `/posts/${params.slug}`,
44
+ 'set-cookie': ['flash=created; Path=/; HttpOnly', 'draft=; Max-Age=0; Path=/'],
45
+ },
46
+ body: {
47
+ created: true,
48
+ draft: request.query.draft === 'true',
49
+ requestId: invocation.context.values.requestId,
50
+ },
51
+ }
52
+ })
53
+ ```
54
+
55
+ Gateway-side call:
56
+
57
+ ```ts
58
+ const response = await callHttpOverMicro(
59
+ app,
60
+ 'cn.zlooks.blog.server',
61
+ '/posts/hello',
62
+ {
63
+ method: ctx.method,
64
+ headers: ctx.headers,
65
+ query: Object.entries(ctx.query).flatMap(([name, value]) =>
66
+ Array.isArray(value) ? value.map(item => [name, item] as const) : [[name, String(value)] as const],
67
+ ),
68
+ body: ctx.request.body,
69
+ },
70
+ { context: executionContext },
71
+ )
72
+
73
+ ctx.status = response.status
74
+ for (const [name, value] of response.headers) ctx.append(name, value)
75
+ return response.body
76
+ ```
77
+
78
+ Pass only gateway-approved end-to-end request headers. The sample assumes a body parser has already produced `ctx.request.body`; use the untouched incoming `Readable` instead for raw uploads.
79
+
80
+ ## Upload and download without a transport switch
81
+
82
+ The caller supplies a normal JSON value or an `AsyncIterable`, `Uint8Array`, or `ArrayBuffer`. `callHttpOverMicro()` snapshots JSON values into the request envelope and automatically moves binary or iterable bodies into Micro request input.
83
+
84
+ ```ts
85
+ import { createReadStream } from 'node:fs'
86
+
87
+ const response = await callHttpOverMicro(app, 'files.server', '/files', {
88
+ method: 'PUT',
89
+ headers: { 'content-type': 'application/octet-stream' },
90
+ body: createReadStream('/tmp/archive.tar'),
91
+ }, { context })
92
+
93
+ if (response.bodyKind === 'stream') {
94
+ for await (const chunk of response.body) consumeDownloadedChunk(chunk)
95
+ }
96
+ ```
97
+
98
+ Provider:
99
+
100
+ ```ts
101
+ import { Readable } from 'node:stream'
102
+
103
+ export default defineHttpOverMicroMessage({ method: ['GET', 'PUT'] }, async ({ request }) => {
104
+ if (request.method === 'PUT') {
105
+ if (!(request.body instanceof Readable)) {
106
+ return { status: 400, body: { error: 'stream required' } }
107
+ }
108
+ await saveUpload(request.body)
109
+ return { status: 204 }
110
+ }
111
+
112
+ return {
113
+ status: 200,
114
+ headers: { 'content-type': 'application/octet-stream' },
115
+ body: openDownloadStream(),
116
+ }
117
+ })
118
+ ```
119
+
120
+ Streaming request bodies are non-replayable. Leave retries unset or set `retries: 0`; an explicit nonzero value fails before dispatch.
121
+
122
+ ## Response metadata before response bytes
123
+
124
+ Every HTTP-over-Micro call uses one Micro response stream. Its first chunk is a response head; body chunks follow only when `body.kind` is `stream`:
125
+
126
+ ```ts
127
+ type RequestEnvelope = {
128
+ protocol: '@hile/http-over-micro'
129
+ version: 1
130
+ type: 'request'
131
+ method: string
132
+ headers: Array<[string, string]>
133
+ query: Array<[string, string]>
134
+ body: { kind: 'empty' } | { kind: 'inline'; value: unknown } | { kind: 'stream' }
135
+ }
136
+
137
+ type ResponseHead = {
138
+ protocol: '@hile/http-over-micro'
139
+ version: 1
140
+ type: 'response'
141
+ status: number
142
+ headers: Array<[string, string]>
143
+ body: { kind: 'empty' } | { kind: 'inline'; value: unknown } | { kind: 'stream' }
144
+ }
145
+ ```
146
+
147
+ Header tuples deliberately preserve order and duplicate fields such as `Set-Cookie`. Header names are canonical lowercase on the wire. Query tuples preserve repeated query values without inventing an object serialization rule.
148
+
149
+ ## Boundaries
150
+
151
+ - Do not use it for ordinary in-process HTTP controllers that do not cross a Micro boundary.
152
+ - Do not use it as a durable queue or retry layer.
153
+ - Do not use it for RSC Flight, MCP, WebSocket upgrades, HTTP trailers, or informational `1xx` responses; those protocols keep their own transports.
154
+ - Do not put business logic in the gateway merely because the gateway performs the HTTP projection.
155
+
156
+ - Do not handcraft the request envelope or consume the response-head frame yourself; use `callHttpOverMicro()`.
157
+ - Do not call `Application.call()` for this protocol. A response may need headers followed by a body stream, so the package intentionally uses `Application.stream()` for every result.
158
+ - Do not encode files as Base64 inside an inline JSON body.
159
+ - Do not collapse response headers into a plain object when duplicate `Set-Cookie` values matter.
160
+ - Do not enable retries for an upload stream.
161
+ - Do not forward every inbound header, raw cookie, or identity credential merely because the protocol can carry it.
162
+
163
+ ## Verify
164
+
165
+ - Unit tests cover inline, empty, malformed, method-mismatch, and Zod-coercion cases.
166
+ - A real Registry-discovered WebSocket test carries request and response streams at the same time.
167
+ - Duplicate response headers survive in order.
168
+ - Invalid request metadata fails with HTTP status `400`; invalid upstream response metadata fails locally with `502`.
169
+ - Inline bodies are bounded and JSON-serializable; files use stream bodies.
170
+ - Cancellation, timeout, idle timeout, and backpressure remain owned by `@hile/micro` and `@hile/message-modem`.
171
+
172
+ ## More Context
173
+
174
+ - `AI.md` in this package: full package-local AI guide.
175
+ - Root `llms-full.txt`: full monorepo AI context.
176
+ - Root `references/`: source files copied from `docs/ai`.
@@ -0,0 +1,33 @@
1
+ import { Readable } from 'node:stream';
2
+ import { type MessageInput } from '@hile/message-modem';
3
+ import type { ApplicationStreamOptions } from '@hile/micro';
4
+ import { type HttpFieldEntry, type HttpFieldInput } from './fields.js';
5
+ import { type HttpOverMicroLimits } from './protocol.js';
6
+ export interface HttpOverMicroApplication {
7
+ stream(namespace: string, url: string, data: unknown, options: ApplicationStreamOptions): Promise<Readable>;
8
+ }
9
+ export interface HttpOverMicroRequest<TBody = unknown> {
10
+ method: string;
11
+ headers?: HttpFieldInput;
12
+ query?: HttpFieldInput;
13
+ body?: TBody | MessageInput;
14
+ }
15
+ export type HttpOverMicroCallOptions = Omit<ApplicationStreamOptions, 'input'> & {
16
+ limits?: HttpOverMicroLimits;
17
+ };
18
+ type ResponseBase = {
19
+ status: number;
20
+ headers: readonly HttpFieldEntry[];
21
+ };
22
+ export type HttpOverMicroResponse<T = unknown> = (ResponseBase & {
23
+ bodyKind: 'empty';
24
+ body: undefined;
25
+ }) | (ResponseBase & {
26
+ bodyKind: 'inline';
27
+ body: T;
28
+ }) | (ResponseBase & {
29
+ bodyKind: 'stream';
30
+ body: Readable;
31
+ });
32
+ export declare function callHttpOverMicro<TResponse = unknown, TRequest = unknown>(application: HttpOverMicroApplication, namespace: string, url: string, request: HttpOverMicroRequest<TRequest>, options: HttpOverMicroCallOptions): Promise<HttpOverMicroResponse<TResponse>>;
33
+ export {};
package/dist/client.js ADDED
@@ -0,0 +1,133 @@
1
+ import { isMessageInput } from '@hile/message-modem';
2
+ import { HttpOverMicroError } from './errors.js';
3
+ import { normalizeHttpHeaders, normalizeHttpQuery, } from './fields.js';
4
+ import { HTTP_OVER_MICRO_PROTOCOL, HTTP_OVER_MICRO_VERSION, httpOverMicroRequestEnvelopeSchema, httpOverMicroResponseHeadSchema, httpResponseMustBeEmpty, normalizeHttpMethod, resolveInlineBodyLimit, snapshotInlineBody, } from './protocol.js';
5
+ const END = Symbol('http-over-micro-end');
6
+ async function readNext(stream) {
7
+ const immediate = stream.read();
8
+ if (immediate !== null)
9
+ return immediate;
10
+ if (stream.readableEnded)
11
+ return END;
12
+ if (stream.destroyed)
13
+ throw stream.errored ?? new Error('Micro response stream closed');
14
+ return await new Promise((resolve, reject) => {
15
+ const cleanup = () => {
16
+ stream.off('readable', onReadable);
17
+ stream.off('end', onEnd);
18
+ stream.off('error', onError);
19
+ stream.off('close', onClose);
20
+ };
21
+ const onReadable = () => {
22
+ const value = stream.read();
23
+ if (value === null)
24
+ return;
25
+ cleanup();
26
+ resolve(value);
27
+ };
28
+ const onEnd = () => {
29
+ cleanup();
30
+ resolve(END);
31
+ };
32
+ const onError = (error) => {
33
+ cleanup();
34
+ reject(error);
35
+ };
36
+ const onClose = () => {
37
+ cleanup();
38
+ if (stream.readableEnded) {
39
+ resolve(END);
40
+ return;
41
+ }
42
+ reject(stream.errored ?? new Error('Micro response stream closed'));
43
+ };
44
+ stream.on('readable', onReadable);
45
+ stream.once('end', onEnd);
46
+ stream.once('error', onError);
47
+ stream.once('close', onClose);
48
+ // Close the read-before-listen race if a chunk arrived while handlers were attached.
49
+ onReadable();
50
+ });
51
+ }
52
+ function invalidResponse(message, cause) {
53
+ return new HttpOverMicroError('INVALID_RESPONSE', 502, message, { cause });
54
+ }
55
+ function invalidRequest(message, cause) {
56
+ return new HttpOverMicroError('INVALID_REQUEST', 400, message, { cause });
57
+ }
58
+ function closeInvalidStream(stream) {
59
+ if (!stream.destroyed)
60
+ stream.destroy();
61
+ }
62
+ export async function callHttpOverMicro(application, namespace, url, request, options) {
63
+ if (!request || typeof request !== 'object') {
64
+ throw invalidRequest('HTTP-over-Micro request must be an object');
65
+ }
66
+ const maxInlineBodyBytes = resolveInlineBodyLimit(options.limits?.maxInlineBodyBytes);
67
+ const input = isMessageInput(request.body) ? request.body : undefined;
68
+ if (input && options.retries !== undefined && options.retries !== 0) {
69
+ throw new TypeError('Streamed HTTP request bodies are non-replayable and require retries: 0');
70
+ }
71
+ let envelope;
72
+ try {
73
+ envelope = {
74
+ protocol: HTTP_OVER_MICRO_PROTOCOL,
75
+ version: HTTP_OVER_MICRO_VERSION,
76
+ type: 'request',
77
+ method: normalizeHttpMethod(request.method),
78
+ headers: normalizeHttpHeaders(request.headers),
79
+ query: normalizeHttpQuery(request.query),
80
+ body: input
81
+ ? { kind: 'stream' }
82
+ : request.body === undefined
83
+ ? { kind: 'empty' }
84
+ : { kind: 'inline', value: snapshotInlineBody(request.body, maxInlineBodyBytes, 'request') },
85
+ };
86
+ }
87
+ catch (cause) {
88
+ if (cause instanceof HttpOverMicroError)
89
+ throw cause;
90
+ throw invalidRequest('Invalid HTTP-over-Micro request metadata', cause);
91
+ }
92
+ const checkedEnvelope = httpOverMicroRequestEnvelopeSchema.safeParse(envelope);
93
+ if (!checkedEnvelope.success) {
94
+ throw invalidRequest('Invalid HTTP-over-Micro request', checkedEnvelope.error);
95
+ }
96
+ const { limits: _limits, ...streamOptions } = options;
97
+ const stream = await application.stream(namespace, url, checkedEnvelope.data, input
98
+ ? { ...streamOptions, input }
99
+ : streamOptions);
100
+ const first = await readNext(stream);
101
+ if (first === END)
102
+ throw invalidResponse('HTTP-over-Micro response ended before its response head');
103
+ const parsedHead = httpOverMicroResponseHeadSchema.safeParse(first);
104
+ if (!parsedHead.success) {
105
+ closeInvalidStream(stream);
106
+ throw invalidResponse('Invalid HTTP-over-Micro response head', parsedHead.error);
107
+ }
108
+ const base = { status: parsedHead.data.status, headers: parsedHead.data.headers };
109
+ if (parsedHead.data.body.kind !== 'empty'
110
+ && httpResponseMustBeEmpty(checkedEnvelope.data.method, parsedHead.data.status)) {
111
+ closeInvalidStream(stream);
112
+ throw invalidResponse(`HTTP ${checkedEnvelope.data.method} response with status ${parsedHead.data.status} must not include a body`);
113
+ }
114
+ if (parsedHead.data.body.kind === 'stream') {
115
+ return { ...base, bodyKind: 'stream', body: stream };
116
+ }
117
+ const trailing = await readNext(stream);
118
+ if (trailing !== END) {
119
+ closeInvalidStream(stream);
120
+ throw invalidResponse('Inline HTTP-over-Micro response contains trailing stream frames');
121
+ }
122
+ if (parsedHead.data.body.kind === 'empty') {
123
+ return { ...base, bodyKind: 'empty', body: undefined };
124
+ }
125
+ let body;
126
+ try {
127
+ body = snapshotInlineBody(parsedHead.data.body.value, maxInlineBodyBytes, 'response');
128
+ }
129
+ catch (cause) {
130
+ throw invalidResponse('Invalid HTTP-over-Micro inline response body', cause);
131
+ }
132
+ return { ...base, bodyKind: 'inline', body: body };
133
+ }
@@ -0,0 +1,14 @@
1
+ import { Exception } from '@hile/message-modem';
2
+ export type HttpOverMicroErrorCode = 'INVALID_DEFINITION' | 'INVALID_REQUEST' | 'INVALID_RESPONSE';
3
+ /**
4
+ * A protocol-boundary failure. `status` survives the Micro transport so an
5
+ * HTTP gateway can distinguish invalid client input from an invalid upstream.
6
+ */
7
+ export declare class HttpOverMicroError extends Exception {
8
+ readonly code: HttpOverMicroErrorCode;
9
+ readonly name = "HttpOverMicroError";
10
+ readonly cause?: unknown;
11
+ constructor(code: HttpOverMicroErrorCode, status: number, message: string, options?: {
12
+ cause?: unknown;
13
+ });
14
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,15 @@
1
+ import { Exception } from '@hile/message-modem';
2
+ /**
3
+ * A protocol-boundary failure. `status` survives the Micro transport so an
4
+ * HTTP gateway can distinguish invalid client input from an invalid upstream.
5
+ */
6
+ export class HttpOverMicroError extends Exception {
7
+ code;
8
+ name = 'HttpOverMicroError';
9
+ cause;
10
+ constructor(code, status, message, options) {
11
+ super(status, message);
12
+ this.code = code;
13
+ this.cause = options?.cause;
14
+ }
15
+ }
@@ -0,0 +1,10 @@
1
+ export type HttpFieldEntry = readonly [name: string, value: string];
2
+ export declare const MAX_HTTP_FIELD_ENTRIES = 256;
3
+ export type HttpFieldInput = Readonly<Record<string, string | readonly string[] | undefined>> | Iterable<HttpFieldEntry>;
4
+ export type HttpFieldValues = Readonly<Record<string, string | readonly string[]>>;
5
+ /** Normalizes header names to lowercase while preserving duplicate values. */
6
+ export declare function normalizeHttpHeaders(input?: HttpFieldInput): Array<[string, string]>;
7
+ /** Preserves query-key casing and duplicate values. */
8
+ export declare function normalizeHttpQuery(input?: HttpFieldInput): Array<[string, string]>;
9
+ /** Converts duplicate entries to string arrays without losing their order. */
10
+ export declare function httpFieldsToRecord(entries: readonly HttpFieldEntry[]): HttpFieldValues;
package/dist/fields.js ADDED
@@ -0,0 +1,66 @@
1
+ export const MAX_HTTP_FIELD_ENTRIES = 256;
2
+ function isIterable(value) {
3
+ return Symbol.iterator in value && typeof value[Symbol.iterator] === 'function';
4
+ }
5
+ function appendEntry(entries, name, value, normalizeName) {
6
+ if (entries.length >= MAX_HTTP_FIELD_ENTRIES) {
7
+ throw new TypeError(`HTTP fields must not contain more than ${MAX_HTTP_FIELD_ENTRIES} entries`);
8
+ }
9
+ if (typeof name !== 'string' || typeof value !== 'string') {
10
+ throw new TypeError('HTTP field names and values must be strings');
11
+ }
12
+ entries.push([normalizeName ? name.toLowerCase() : name, value]);
13
+ }
14
+ function normalizeEntries(input, normalizeName) {
15
+ if (input === undefined)
16
+ return [];
17
+ if (!input || typeof input !== 'object') {
18
+ throw new TypeError('HTTP fields must be a record or an iterable of string pairs');
19
+ }
20
+ const entries = [];
21
+ if (isIterable(input)) {
22
+ for (const pair of input) {
23
+ if (!Array.isArray(pair) || pair.length !== 2) {
24
+ throw new TypeError('HTTP field iterables must contain [name, value] pairs');
25
+ }
26
+ appendEntry(entries, pair[0], pair[1], normalizeName);
27
+ }
28
+ return entries;
29
+ }
30
+ for (const [name, value] of Object.entries(input)) {
31
+ if (value === undefined)
32
+ continue;
33
+ if (typeof value === 'string') {
34
+ appendEntry(entries, name, value, normalizeName);
35
+ continue;
36
+ }
37
+ if (!Array.isArray(value)) {
38
+ throw new TypeError('HTTP field record values must be strings or string arrays');
39
+ }
40
+ for (const item of value)
41
+ appendEntry(entries, name, item, normalizeName);
42
+ }
43
+ return entries;
44
+ }
45
+ /** Normalizes header names to lowercase while preserving duplicate values. */
46
+ export function normalizeHttpHeaders(input) {
47
+ return normalizeEntries(input, true);
48
+ }
49
+ /** Preserves query-key casing and duplicate values. */
50
+ export function normalizeHttpQuery(input) {
51
+ return normalizeEntries(input, false);
52
+ }
53
+ /** Converts duplicate entries to string arrays without losing their order. */
54
+ export function httpFieldsToRecord(entries) {
55
+ const output = Object.create(null);
56
+ for (const [name, value] of entries) {
57
+ const current = output[name];
58
+ if (current === undefined)
59
+ output[name] = value;
60
+ else if (typeof current === 'string')
61
+ output[name] = [current, value];
62
+ else
63
+ output[name] = [...current, value];
64
+ }
65
+ return output;
66
+ }
@@ -0,0 +1,5 @@
1
+ export { callHttpOverMicro, type HttpOverMicroApplication, type HttpOverMicroCallOptions, type HttpOverMicroRequest, type HttpOverMicroResponse, } from './client.js';
2
+ export { HttpOverMicroError, type HttpOverMicroErrorCode } from './errors.js';
3
+ export { MAX_HTTP_FIELD_ENTRIES, httpFieldsToRecord, normalizeHttpHeaders, normalizeHttpQuery, type HttpFieldEntry, type HttpFieldInput, type HttpFieldValues, } from './fields.js';
4
+ export { DEFAULT_MAX_INLINE_BODY_BYTES, HTTP_OVER_MICRO_PROTOCOL, HTTP_OVER_MICRO_VERSION, httpOverMicroRequestEnvelopeSchema, httpOverMicroResponseHeadSchema, type HttpOverMicroBodyDescriptor, type HttpOverMicroLimits, type HttpOverMicroRequestEnvelope, type HttpOverMicroResponseHead, } from './protocol.js';
5
+ export { defineHttpOverMicroMessage, type HttpOverMicroHandler, type HttpOverMicroHandlerContext, type HttpOverMicroHandlerResponse, type HttpOverMicroMessageConfig, type HttpOverMicroSchemas, } from './server.js';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { callHttpOverMicro, } from './client.js';
2
+ export { HttpOverMicroError } from './errors.js';
3
+ export { MAX_HTTP_FIELD_ENTRIES, httpFieldsToRecord, normalizeHttpHeaders, normalizeHttpQuery, } from './fields.js';
4
+ export { DEFAULT_MAX_INLINE_BODY_BYTES, HTTP_OVER_MICRO_PROTOCOL, HTTP_OVER_MICRO_VERSION, httpOverMicroRequestEnvelopeSchema, httpOverMicroResponseHeadSchema, } from './protocol.js';
5
+ export { defineHttpOverMicroMessage, } from './server.js';
@@ -0,0 +1,68 @@
1
+ import { z } from 'zod';
2
+ import { type HttpFieldEntry } from './fields.js';
3
+ export declare const HTTP_OVER_MICRO_PROTOCOL: "@hile/http-over-micro";
4
+ export declare const HTTP_OVER_MICRO_VERSION: 1;
5
+ export declare const DEFAULT_MAX_INLINE_BODY_BYTES: number;
6
+ export interface HttpOverMicroLimits {
7
+ maxInlineBodyBytes?: number;
8
+ }
9
+ export type HttpOverMicroBodyDescriptor<T = unknown> = {
10
+ kind: 'empty';
11
+ } | {
12
+ kind: 'inline';
13
+ value: T;
14
+ } | {
15
+ kind: 'stream';
16
+ };
17
+ export interface HttpOverMicroRequestEnvelope<T = unknown> {
18
+ protocol: typeof HTTP_OVER_MICRO_PROTOCOL;
19
+ version: typeof HTTP_OVER_MICRO_VERSION;
20
+ type: 'request';
21
+ method: string;
22
+ headers: readonly HttpFieldEntry[];
23
+ query: readonly HttpFieldEntry[];
24
+ body: HttpOverMicroBodyDescriptor<T>;
25
+ }
26
+ export interface HttpOverMicroResponseHead<T = unknown> {
27
+ protocol: typeof HTTP_OVER_MICRO_PROTOCOL;
28
+ version: typeof HTTP_OVER_MICRO_VERSION;
29
+ type: 'response';
30
+ status: number;
31
+ headers: readonly HttpFieldEntry[];
32
+ body: HttpOverMicroBodyDescriptor<T>;
33
+ }
34
+ export declare const httpOverMicroRequestEnvelopeSchema: z.ZodObject<{
35
+ protocol: z.ZodLiteral<"@hile/http-over-micro">;
36
+ version: z.ZodLiteral<1>;
37
+ type: z.ZodLiteral<"request">;
38
+ method: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
39
+ headers: z.ZodArray<z.ZodTuple<[z.ZodString, z.ZodString], null>>;
40
+ query: z.ZodArray<z.ZodTuple<[z.ZodString, z.ZodString], null>>;
41
+ body: z.ZodDiscriminatedUnion<[z.ZodObject<{
42
+ kind: z.ZodLiteral<"empty">;
43
+ }, z.core.$strict>, z.ZodObject<{
44
+ kind: z.ZodLiteral<"inline">;
45
+ value: z.ZodUnknown;
46
+ }, z.core.$strict>, z.ZodObject<{
47
+ kind: z.ZodLiteral<"stream">;
48
+ }, z.core.$strict>], "kind">;
49
+ }, z.core.$strict>;
50
+ export declare const httpOverMicroResponseHeadSchema: z.ZodObject<{
51
+ protocol: z.ZodLiteral<"@hile/http-over-micro">;
52
+ version: z.ZodLiteral<1>;
53
+ type: z.ZodLiteral<"response">;
54
+ status: z.ZodNumber;
55
+ headers: z.ZodArray<z.ZodTuple<[z.ZodString, z.ZodString], null>>;
56
+ body: z.ZodDiscriminatedUnion<[z.ZodObject<{
57
+ kind: z.ZodLiteral<"empty">;
58
+ }, z.core.$strict>, z.ZodObject<{
59
+ kind: z.ZodLiteral<"inline">;
60
+ value: z.ZodUnknown;
61
+ }, z.core.$strict>, z.ZodObject<{
62
+ kind: z.ZodLiteral<"stream">;
63
+ }, z.core.$strict>], "kind">;
64
+ }, z.core.$strict>;
65
+ export declare function normalizeHttpMethod(value: string): string;
66
+ export declare function resolveInlineBodyLimit(value?: number): number;
67
+ export declare function httpResponseMustBeEmpty(method: string, status: number): boolean;
68
+ export declare function snapshotInlineBody(value: unknown, maxBytes: number, subject: 'request' | 'response'): unknown;
@@ -0,0 +1,96 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { z } from 'zod';
3
+ import { HttpOverMicroError } from './errors.js';
4
+ import { MAX_HTTP_FIELD_ENTRIES } from './fields.js';
5
+ export const HTTP_OVER_MICRO_PROTOCOL = '@hile/http-over-micro';
6
+ export const HTTP_OVER_MICRO_VERSION = 1;
7
+ export const DEFAULT_MAX_INLINE_BODY_BYTES = 1024 * 1024;
8
+ const MAX_METHOD_LENGTH = 64;
9
+ const MAX_FIELD_NAME_LENGTH = 256;
10
+ const MAX_FIELD_VALUE_LENGTH = 16 * 1024;
11
+ const MAX_FIELDS_BYTES = 64 * 1024;
12
+ const HTTP_TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
13
+ const methodSchema = z.string()
14
+ .min(1)
15
+ .max(MAX_METHOD_LENGTH)
16
+ .regex(HTTP_TOKEN)
17
+ .transform(value => value.toUpperCase());
18
+ const headerEntrySchema = z.tuple([
19
+ z.string().min(1).max(MAX_FIELD_NAME_LENGTH).regex(HTTP_TOKEN)
20
+ .refine(value => value === value.toLowerCase(), 'Header names must be lowercase'),
21
+ z.string().max(MAX_FIELD_VALUE_LENGTH).refine(value => !/[\r\n\0]/.test(value), 'Invalid header value'),
22
+ ]);
23
+ const queryEntrySchema = z.tuple([
24
+ z.string().max(MAX_FIELD_NAME_LENGTH).refine(value => !value.includes('\0'), 'Invalid query name'),
25
+ z.string().max(MAX_FIELD_VALUE_LENGTH).refine(value => !value.includes('\0'), 'Invalid query value'),
26
+ ]);
27
+ function boundedEntries(schema) {
28
+ return z.array(schema).max(MAX_HTTP_FIELD_ENTRIES).superRefine((entries, context) => {
29
+ let bytes = 0;
30
+ for (const [name, value] of entries) {
31
+ bytes += Buffer.byteLength(name) + Buffer.byteLength(value);
32
+ if (bytes > MAX_FIELDS_BYTES) {
33
+ context.addIssue({ code: 'custom', message: `HTTP fields must not exceed ${MAX_FIELDS_BYTES} bytes` });
34
+ return;
35
+ }
36
+ }
37
+ });
38
+ }
39
+ const bodyDescriptorSchema = z.discriminatedUnion('kind', [
40
+ z.object({ kind: z.literal('empty') }).strict(),
41
+ z.object({ kind: z.literal('inline'), value: z.unknown() }).strict()
42
+ .refine(body => body.value !== undefined, 'Inline body value must not be undefined'),
43
+ z.object({ kind: z.literal('stream') }).strict(),
44
+ ]);
45
+ export const httpOverMicroRequestEnvelopeSchema = z.object({
46
+ protocol: z.literal(HTTP_OVER_MICRO_PROTOCOL),
47
+ version: z.literal(HTTP_OVER_MICRO_VERSION),
48
+ type: z.literal('request'),
49
+ method: methodSchema,
50
+ headers: boundedEntries(headerEntrySchema),
51
+ query: boundedEntries(queryEntrySchema),
52
+ body: bodyDescriptorSchema,
53
+ }).strict();
54
+ export const httpOverMicroResponseHeadSchema = z.object({
55
+ protocol: z.literal(HTTP_OVER_MICRO_PROTOCOL),
56
+ version: z.literal(HTTP_OVER_MICRO_VERSION),
57
+ type: z.literal('response'),
58
+ status: z.number().int().min(200).max(599),
59
+ headers: boundedEntries(headerEntrySchema),
60
+ body: bodyDescriptorSchema,
61
+ }).strict();
62
+ export function normalizeHttpMethod(value) {
63
+ const parsed = methodSchema.safeParse(value);
64
+ if (!parsed.success) {
65
+ throw new HttpOverMicroError('INVALID_REQUEST', 400, 'Invalid HTTP method', { cause: parsed.error });
66
+ }
67
+ return parsed.data;
68
+ }
69
+ export function resolveInlineBodyLimit(value) {
70
+ if (value === undefined)
71
+ return DEFAULT_MAX_INLINE_BODY_BYTES;
72
+ if (!Number.isSafeInteger(value) || value < 1) {
73
+ throw new TypeError('maxInlineBodyBytes must be a positive safe integer');
74
+ }
75
+ return value;
76
+ }
77
+ export function httpResponseMustBeEmpty(method, status) {
78
+ return method === 'HEAD' || status === 204 || status === 205 || status === 304;
79
+ }
80
+ export function snapshotInlineBody(value, maxBytes, subject) {
81
+ let serialized;
82
+ try {
83
+ serialized = JSON.stringify(value);
84
+ }
85
+ catch (cause) {
86
+ throw new HttpOverMicroError(subject === 'request' ? 'INVALID_REQUEST' : 'INVALID_RESPONSE', subject === 'request' ? 400 : 500, `${subject === 'request' ? 'Request' : 'Response'} inline body must be JSON-serializable`, { cause });
87
+ }
88
+ if (serialized === undefined) {
89
+ throw new HttpOverMicroError(subject === 'request' ? 'INVALID_REQUEST' : 'INVALID_RESPONSE', subject === 'request' ? 400 : 500, `${subject === 'request' ? 'Request' : 'Response'} inline body must be JSON-serializable`);
90
+ }
91
+ const bytes = Buffer.byteLength(serialized);
92
+ if (bytes > maxBytes) {
93
+ throw new HttpOverMicroError(subject === 'request' ? 'INVALID_REQUEST' : 'INVALID_RESPONSE', subject === 'request' ? 413 : 500, `${subject === 'request' ? 'Request' : 'Response'} inline body exceeds ${maxBytes} bytes`);
94
+ }
95
+ return JSON.parse(serialized);
96
+ }
@@ -0,0 +1,41 @@
1
+ import { Readable } from 'node:stream';
2
+ import { type MessageInput } from '@hile/message-modem';
3
+ import { type Client, type MicroMessageHandlerExtras, type MicroMessageMetadata } from '@hile/micro';
4
+ import type { MessageRegisterProps } from '@hile/message-loader';
5
+ import type { z } from 'zod';
6
+ import { type HttpFieldInput, type HttpFieldValues } from './fields.js';
7
+ import { type HttpOverMicroRequestEnvelope, type HttpOverMicroLimits } from './protocol.js';
8
+ export interface HttpOverMicroSchemas {
9
+ headers?: z.ZodType;
10
+ query?: z.ZodType;
11
+ params?: z.ZodType;
12
+ body?: z.ZodType;
13
+ }
14
+ type SchemaOutput<TSchema, TFallback> = TSchema extends z.ZodType ? z.output<TSchema> : TFallback;
15
+ export interface HttpOverMicroMessageConfig<TSchemas extends HttpOverMicroSchemas = HttpOverMicroSchemas> {
16
+ method: string | readonly string[];
17
+ schema?: TSchemas;
18
+ limits?: HttpOverMicroLimits;
19
+ }
20
+ export interface HttpOverMicroHandlerResponse<TBody = unknown> {
21
+ status?: number;
22
+ headers?: HttpFieldInput;
23
+ body?: TBody | MessageInput;
24
+ }
25
+ export interface HttpOverMicroHandlerContext<TSchemas extends HttpOverMicroSchemas = HttpOverMicroSchemas> {
26
+ request: {
27
+ method: string;
28
+ headers: SchemaOutput<TSchemas['headers'], HttpFieldValues>;
29
+ query: SchemaOutput<TSchemas['query'], HttpFieldValues>;
30
+ body: SchemaOutput<TSchemas['body'], unknown | Readable | undefined>;
31
+ };
32
+ params: SchemaOutput<TSchemas['params'], Record<string, string>>;
33
+ url: string;
34
+ client: Client;
35
+ metadata?: MicroMessageMetadata;
36
+ signal?: AbortSignal;
37
+ invocation: MicroMessageHandlerExtras['invocation'];
38
+ }
39
+ export type HttpOverMicroHandler<TSchemas extends HttpOverMicroSchemas = HttpOverMicroSchemas> = (context: HttpOverMicroHandlerContext<TSchemas>) => HttpOverMicroHandlerResponse | Promise<HttpOverMicroHandlerResponse>;
40
+ export declare function defineHttpOverMicroMessage<const TSchemas extends HttpOverMicroSchemas = HttpOverMicroSchemas>(config: HttpOverMicroMessageConfig<TSchemas>, handler: HttpOverMicroHandler<TSchemas>): MessageRegisterProps<HttpOverMicroRequestEnvelope, MicroMessageHandlerExtras>;
41
+ export {};
package/dist/server.js ADDED
@@ -0,0 +1,163 @@
1
+ import { isMessageInput } from '@hile/message-modem';
2
+ import { defineMicroMessage, } from '@hile/micro';
3
+ import { HttpOverMicroError } from './errors.js';
4
+ import { httpFieldsToRecord, normalizeHttpHeaders, } from './fields.js';
5
+ import { HTTP_OVER_MICRO_PROTOCOL, HTTP_OVER_MICRO_VERSION, httpOverMicroRequestEnvelopeSchema, httpOverMicroResponseHeadSchema, httpResponseMustBeEmpty, normalizeHttpMethod, resolveInlineBodyLimit, snapshotInlineBody, } from './protocol.js';
6
+ function invalidRequest(message, cause) {
7
+ return new HttpOverMicroError('INVALID_REQUEST', 400, message, { cause });
8
+ }
9
+ function invalidResponse(message, cause) {
10
+ return new HttpOverMicroError('INVALID_RESPONSE', 500, message, { cause });
11
+ }
12
+ async function parseWithSchema(schema, value, field) {
13
+ if (!schema)
14
+ return value;
15
+ const parsed = await schema.safeParseAsync(value);
16
+ if (!parsed.success)
17
+ throw invalidRequest(`Invalid HTTP request ${field}`, parsed.error);
18
+ return parsed.data;
19
+ }
20
+ function normalizeAllowedMethods(method) {
21
+ try {
22
+ let source;
23
+ if (typeof method === 'string')
24
+ source = [method];
25
+ else if (Array.isArray(method))
26
+ source = method;
27
+ else
28
+ throw new TypeError('method must be a string or string array');
29
+ if (source.length === 0)
30
+ throw new TypeError('At least one HTTP method is required');
31
+ return Object.freeze([...new Set(source.map(normalizeHttpMethod))]);
32
+ }
33
+ catch (cause) {
34
+ throw new HttpOverMicroError('INVALID_DEFINITION', 500, 'HTTP-over-Micro methods are invalid', { cause });
35
+ }
36
+ }
37
+ function validateSchemas(schemas) {
38
+ if (schemas === undefined)
39
+ return;
40
+ if (!schemas || typeof schemas !== 'object' || Array.isArray(schemas)) {
41
+ throw new HttpOverMicroError('INVALID_DEFINITION', 500, 'HTTP-over-Micro schema must be an object');
42
+ }
43
+ for (const field of ['headers', 'query', 'params', 'body']) {
44
+ const schema = schemas[field];
45
+ if (schema !== undefined && typeof schema.safeParseAsync !== 'function') {
46
+ throw new HttpOverMicroError('INVALID_DEFINITION', 500, `HTTP-over-Micro ${field} schema must be a Zod schema`);
47
+ }
48
+ }
49
+ }
50
+ async function* iterateBody(body) {
51
+ if (body instanceof ArrayBuffer) {
52
+ yield new Uint8Array(body);
53
+ return;
54
+ }
55
+ if (body instanceof Uint8Array) {
56
+ yield body;
57
+ return;
58
+ }
59
+ for await (const chunk of body) {
60
+ if (chunk === null || chunk === undefined)
61
+ throw invalidResponse('HTTP response stream emitted an empty chunk');
62
+ yield chunk;
63
+ }
64
+ }
65
+ function createHead(status, headers, body) {
66
+ let candidate;
67
+ try {
68
+ candidate = {
69
+ protocol: HTTP_OVER_MICRO_PROTOCOL,
70
+ version: HTTP_OVER_MICRO_VERSION,
71
+ type: 'response',
72
+ status,
73
+ headers: normalizeHttpHeaders(headers),
74
+ body,
75
+ };
76
+ }
77
+ catch (cause) {
78
+ throw invalidResponse('Invalid HTTP response headers', cause);
79
+ }
80
+ const parsed = httpOverMicroResponseHeadSchema.safeParse(candidate);
81
+ if (!parsed.success)
82
+ throw invalidResponse('Invalid HTTP response metadata', parsed.error);
83
+ return parsed.data;
84
+ }
85
+ export function defineHttpOverMicroMessage(config, handler) {
86
+ if (!config || typeof config !== 'object') {
87
+ throw new HttpOverMicroError('INVALID_DEFINITION', 500, 'HTTP-over-Micro config is required');
88
+ }
89
+ if (typeof handler !== 'function') {
90
+ throw new HttpOverMicroError('INVALID_DEFINITION', 500, 'HTTP-over-Micro handler is required');
91
+ }
92
+ const methods = normalizeAllowedMethods(config.method);
93
+ const methodSet = new Set(methods);
94
+ let maxInlineBodyBytes;
95
+ try {
96
+ maxInlineBodyBytes = resolveInlineBodyLimit(config.limits?.maxInlineBodyBytes);
97
+ }
98
+ catch (cause) {
99
+ throw new HttpOverMicroError('INVALID_DEFINITION', 500, 'HTTP-over-Micro limits are invalid', { cause });
100
+ }
101
+ const schemas = config.schema;
102
+ validateSchemas(schemas);
103
+ return defineMicroMessage(async function* ({ data, input, params, url, client, metadata, signal, invocation, }) {
104
+ const parsedEnvelope = httpOverMicroRequestEnvelopeSchema.safeParse(data);
105
+ if (!parsedEnvelope.success) {
106
+ throw invalidRequest('Invalid HTTP-over-Micro request envelope', parsedEnvelope.error);
107
+ }
108
+ const envelope = parsedEnvelope.data;
109
+ if (!methodSet.has(envelope.method)) {
110
+ yield createHead(405, { allow: methods.join(', ') }, { kind: 'empty' });
111
+ return;
112
+ }
113
+ let body;
114
+ if (envelope.body.kind === 'stream') {
115
+ if (!input)
116
+ throw invalidRequest('HTTP request declares a stream body but no Micro input was received');
117
+ body = input;
118
+ }
119
+ else {
120
+ if (input)
121
+ throw invalidRequest('HTTP request sent Micro input without declaring a stream body');
122
+ body = envelope.body.kind === 'inline' ? envelope.body.value : undefined;
123
+ }
124
+ const request = {
125
+ method: envelope.method,
126
+ headers: await parseWithSchema(schemas?.headers, httpFieldsToRecord(envelope.headers), 'headers'),
127
+ query: await parseWithSchema(schemas?.query, httpFieldsToRecord(envelope.query), 'query'),
128
+ body: await parseWithSchema(schemas?.body, body, 'body'),
129
+ };
130
+ const parsedParams = (await parseWithSchema(schemas?.params, params ?? {}, 'params'));
131
+ const response = await handler({
132
+ request,
133
+ params: parsedParams,
134
+ url,
135
+ client,
136
+ metadata,
137
+ signal,
138
+ invocation,
139
+ });
140
+ if (!response || typeof response !== 'object' || Array.isArray(response)) {
141
+ throw invalidResponse('HTTP-over-Micro handler must return a response object');
142
+ }
143
+ const status = response.status ?? 200;
144
+ if (!Number.isInteger(status) || status < 200 || status > 599) {
145
+ throw invalidResponse('HTTP response status must be an integer from 200 through 599');
146
+ }
147
+ const responseBody = response.body;
148
+ if (responseBody !== undefined && httpResponseMustBeEmpty(envelope.method, status)) {
149
+ throw invalidResponse(`HTTP ${envelope.method} response with status ${status} must not include a body`);
150
+ }
151
+ if (responseBody === undefined) {
152
+ yield createHead(status, response.headers, { kind: 'empty' });
153
+ return;
154
+ }
155
+ if (isMessageInput(responseBody)) {
156
+ yield createHead(status, response.headers, { kind: 'stream' });
157
+ yield* iterateBody(responseBody);
158
+ return;
159
+ }
160
+ const inlineBody = snapshotInlineBody(responseBody, maxInlineBodyBytes, 'response');
161
+ yield createHead(status, response.headers, { kind: 'inline', value: inlineBody });
162
+ });
163
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@hile/http-over-micro",
3
+ "description": "Preserve HTTP semantics across Hile Micro calls with validated metadata and bidirectional streaming",
4
+ "version": "1.0.1",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "build": "tsc -b && fix-esm-import-path --preserve-import-type ./dist",
16
+ "dev": "tsc -b --watch",
17
+ "test": "vitest run"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "!dist/**/*.test.*",
22
+ "README.md",
23
+ "AI.md"
24
+ ],
25
+ "license": "MIT",
26
+ "engines": {
27
+ "node": ">=20.12.0"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "dependencies": {
33
+ "@hile/message-loader": "^4.0.5",
34
+ "@hile/message-modem": "^4.0.4",
35
+ "@hile/micro": "^4.0.5",
36
+ "zod": "^4.3.6"
37
+ },
38
+ "devDependencies": {
39
+ "@hile/context": "^4.0.3",
40
+ "@types/node": "^26.2.0",
41
+ "fix-esm-import-path": "^1.10.3",
42
+ "vitest": "^4.0.18"
43
+ },
44
+ "gitHead": "c57b2c5a3c017ae56d4a862a089b2af01368c3ba"
45
+ }