@arki/http 0.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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +229 -0
  3. package/dist/bundle.d.ts +121 -0
  4. package/dist/bundle.d.ts.map +1 -0
  5. package/dist/bundle.js +98 -0
  6. package/dist/bundle.js.map +1 -0
  7. package/dist/context.d.ts +29 -0
  8. package/dist/context.d.ts.map +1 -0
  9. package/dist/context.js +34 -0
  10. package/dist/context.js.map +1 -0
  11. package/dist/contract.d.ts +120 -0
  12. package/dist/contract.d.ts.map +1 -0
  13. package/dist/contract.js +49 -0
  14. package/dist/contract.js.map +1 -0
  15. package/dist/derive.d.ts +58 -0
  16. package/dist/derive.d.ts.map +1 -0
  17. package/dist/derive.js +28 -0
  18. package/dist/derive.js.map +1 -0
  19. package/dist/dot.d.ts +106 -0
  20. package/dist/dot.d.ts.map +1 -0
  21. package/dist/dot.js +210 -0
  22. package/dist/dot.js.map +1 -0
  23. package/dist/engine.d.ts +36 -0
  24. package/dist/engine.d.ts.map +1 -0
  25. package/dist/engine.js +173 -0
  26. package/dist/engine.js.map +1 -0
  27. package/dist/error.d.ts +107 -0
  28. package/dist/error.d.ts.map +1 -0
  29. package/dist/error.js +122 -0
  30. package/dist/error.js.map +1 -0
  31. package/dist/index.d.ts +28 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +21 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/listen.d.ts +23 -0
  36. package/dist/listen.d.ts.map +1 -0
  37. package/dist/listen.js +71 -0
  38. package/dist/listen.js.map +1 -0
  39. package/dist/openapi.d.ts +45 -0
  40. package/dist/openapi.d.ts.map +1 -0
  41. package/dist/openapi.js +140 -0
  42. package/dist/openapi.js.map +1 -0
  43. package/dist/sse.d.ts +12 -0
  44. package/dist/sse.d.ts.map +1 -0
  45. package/dist/sse.js +86 -0
  46. package/dist/sse.js.map +1 -0
  47. package/package.json +73 -0
  48. package/src/bundle.ts +221 -0
  49. package/src/context.ts +39 -0
  50. package/src/contract.ts +152 -0
  51. package/src/derive.ts +67 -0
  52. package/src/dot.ts +282 -0
  53. package/src/engine.ts +239 -0
  54. package/src/error.ts +158 -0
  55. package/src/index.ts +53 -0
  56. package/src/listen.ts +100 -0
  57. package/src/openapi.ts +168 -0
  58. package/src/sse.ts +92 -0
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Route contracts — the static half of an HTTP surface.
3
+ *
4
+ * A contract is pure data at module scope: method + path + zod schemas +
5
+ * a stable id. No handler, no services, no I/O. That split is what makes
6
+ * three things possible at once:
7
+ *
8
+ * - `configure` can register it in the DOT manifest (configure is sync);
9
+ * - OpenAPI generation is static — no boot required;
10
+ * - the handler signature is fully inferred when the contract is bound
11
+ * (see `routes().bind()` in `bundle.ts`).
12
+ */
13
+
14
+ import type { z } from 'zod';
15
+
16
+ /** Methods a {@link RouteContract} can declare. */
17
+ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
18
+
19
+ /**
20
+ * Path-parameter names extracted from a path literal at the type level:
21
+ * `'/orders/:id/items/:itemId'` → `'id' | 'itemId'`.
22
+ */
23
+ export type PathParams<TPath extends string> = TPath extends `${string}:${infer Rest}`
24
+ ? Rest extends `${infer Param}/${infer Tail}`
25
+ ? Param | PathParams<`/${Tail}`>
26
+ : Rest
27
+ : never;
28
+
29
+ /** Typed view of a path's parameters: every value is a string. */
30
+ export type ParamsOf<TPath extends string> = Readonly<Record<PathParams<TPath>, string>>;
31
+
32
+ /**
33
+ * The minimal schema surface the engine needs (`parse` only). Every zod
34
+ * schema satisfies it structurally, which lets mixed-generic contracts
35
+ * live in one array without variance fights. Values MUST be zod schemas —
36
+ * `toOpenApi` converts them via `z.toJSONSchema`.
37
+ */
38
+ export type SchemaLike = {
39
+ parse(input: unknown): unknown;
40
+ };
41
+
42
+ /**
43
+ * A JSON route contract. Create via {@link route}. The generics carry the
44
+ * validated input/output types into the bound handler's signature.
45
+ */
46
+ export type RouteContract<TPath extends string = string, TQuery = undefined, TBody = undefined, TOutput = undefined> = {
47
+ readonly kind: 'http';
48
+ /** Stable identifier — lands in the manifest and OpenAPI `operationId`. */
49
+ readonly id: string;
50
+ readonly method: HttpMethod;
51
+ readonly path: TPath;
52
+ readonly summary?: string;
53
+ readonly query?: z.ZodType<TQuery>;
54
+ readonly body?: z.ZodType<TBody>;
55
+ readonly output?: z.ZodType<TOutput>;
56
+ };
57
+
58
+ /**
59
+ * A typed server-sent-events contract. Create via {@link route.sse}. The
60
+ * bound handler is an async generator; every yielded value is validated
61
+ * against `event` and framed as an SSE `data:` message.
62
+ */
63
+ export type SseContract<TPath extends string = string, TQuery = undefined, TEvent = unknown> = {
64
+ readonly kind: 'sse';
65
+ readonly id: string;
66
+ readonly method: 'GET';
67
+ readonly path: TPath;
68
+ readonly summary?: string;
69
+ readonly query?: z.ZodType<TQuery>;
70
+ /** Schema every yielded event is validated against. */
71
+ readonly event: z.ZodType<TEvent>;
72
+ /** Emit `:keepalive` comments at this interval. Off when omitted. */
73
+ readonly heartbeatMs?: number;
74
+ };
75
+
76
+ /**
77
+ * Generics-erased view of a contract — what bundles store and the engine,
78
+ * manifest registration, and OpenAPI generation consume. Both contract
79
+ * types are structurally assignable to it; no casts involved.
80
+ */
81
+ export type ContractLike = {
82
+ readonly kind: 'http' | 'sse';
83
+ readonly id: string;
84
+ readonly method: HttpMethod;
85
+ readonly path: string;
86
+ readonly summary?: string;
87
+ readonly query?: SchemaLike;
88
+ readonly body?: SchemaLike;
89
+ readonly output?: SchemaLike;
90
+ readonly event?: SchemaLike;
91
+ readonly heartbeatMs?: number;
92
+ };
93
+
94
+ type RouteDef<TQuery, TBody, TOutput> = {
95
+ readonly id: string;
96
+ readonly summary?: string;
97
+ readonly query?: z.ZodType<TQuery>;
98
+ readonly body?: z.ZodType<TBody>;
99
+ readonly output?: z.ZodType<TOutput>;
100
+ };
101
+
102
+ const makeRoute =
103
+ <TMethod extends HttpMethod>(method: TMethod) =>
104
+ <TPath extends string, TQuery = undefined, TBody = undefined, TOutput = undefined>(
105
+ path: TPath,
106
+ def: RouteDef<TQuery, TBody, TOutput>,
107
+ ): RouteContract<TPath, TQuery, TBody, TOutput> => ({
108
+ kind: 'http',
109
+ method,
110
+ path,
111
+ ...def,
112
+ });
113
+
114
+ /**
115
+ * Contract factories, one per method, plus {@link route.sse} for typed
116
+ * event streams.
117
+ *
118
+ * ```ts
119
+ * export const listOrders = route.get('/orders', {
120
+ * id: 'orders.list',
121
+ * query: z.object({ status: z.enum(['open', 'shipped']).optional() }),
122
+ * output: z.array(Order),
123
+ * });
124
+ *
125
+ * export const progress = route.sse('/orders/:id/progress', {
126
+ * id: 'orders.progress',
127
+ * event: z.discriminatedUnion('type', [Queued, Shipped]),
128
+ * });
129
+ * ```
130
+ */
131
+ export const route = {
132
+ get: makeRoute('GET'),
133
+ post: makeRoute('POST'),
134
+ put: makeRoute('PUT'),
135
+ patch: makeRoute('PATCH'),
136
+ delete: makeRoute('DELETE'),
137
+ sse: <TPath extends string, TEvent, TQuery = undefined>(
138
+ path: TPath,
139
+ def: {
140
+ readonly id: string;
141
+ readonly summary?: string;
142
+ readonly query?: z.ZodType<TQuery>;
143
+ readonly event: z.ZodType<TEvent>;
144
+ readonly heartbeatMs?: number;
145
+ },
146
+ ): SseContract<TPath, TQuery, TEvent> => ({
147
+ kind: 'sse',
148
+ method: 'GET',
149
+ path,
150
+ ...def,
151
+ }),
152
+ };
package/src/derive.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Request-scope derivers — "scope is data, not containers."
3
+ *
4
+ * A deriver is a named function from `(req, ctx)` to a value; each one
5
+ * adds a typed key to the per-request context handlers receive. Derivers
6
+ * run in declaration order, later derivers see earlier keys typed, and a
7
+ * deriver that throws {@link HttpError} short-circuits into the wire
8
+ * envelope — which is the entire "guard" story:
9
+ *
10
+ * ```ts
11
+ * const withPrincipal = derive('principal', async req => {
12
+ * const p = await auth.verify(req.headers.get('authorization'));
13
+ * if (!p) throw new HttpError(401, HTTP_ERROR_CODES.unauthorized, 'missing or invalid credentials');
14
+ * return p;
15
+ * });
16
+ * ```
17
+ *
18
+ * Because bundles are assembled inside a pip's `boot`, derivers close over
19
+ * injected singletons — request scope is function application over the
20
+ * compile-time-wired service graph, not per-request container resolution.
21
+ */
22
+
23
+ /**
24
+ * The context every handler and deriver receives. Derivers extend it with
25
+ * their own typed keys; the base keys are reserved.
26
+ */
27
+ export type BaseRequestCtx = {
28
+ readonly req: Request;
29
+ readonly url: URL;
30
+ /** Inbound `x-request-id` header, else a fresh UUID. */
31
+ readonly requestId: string;
32
+ /** Aborts when the client disconnects. */
33
+ readonly signal: AbortSignal;
34
+ };
35
+
36
+ /** Base context keys — derivers may not shadow these. */
37
+ export const BASE_CTX_KEYS = ['req', 'url', 'requestId', 'signal'] as const;
38
+
39
+ /**
40
+ * A named, typed context deriver. `TIn` is the context the deriver needs
41
+ * to see — a deriver written against {@link BaseRequestCtx} composes into
42
+ * any bundle; one written against a richer context can only be added
43
+ * after the derivers that provide it.
44
+ */
45
+ export type Deriver<K extends string = string, V = unknown, TIn extends BaseRequestCtx = BaseRequestCtx> = {
46
+ readonly key: K;
47
+ readonly derive: (req: Request, ctx: TIn) => V | Promise<V>;
48
+ };
49
+
50
+ /** Create a reusable {@link Deriver}. */
51
+ export function derive<K extends string, V, TIn extends BaseRequestCtx = BaseRequestCtx>(
52
+ key: K,
53
+ fn: (req: Request, ctx: TIn) => V | Promise<V>,
54
+ ): Deriver<K, V, TIn> {
55
+ return { key, derive: fn };
56
+ }
57
+
58
+ /**
59
+ * Generics-erased deriver view for bundle storage. Typed derivers are
60
+ * assignable via parameter contravariance from the bottom type; the
61
+ * engine crosses the erasure boundary at the call site (`ctx as never`),
62
+ * mirroring the DOT kernel's hook-storage pattern.
63
+ */
64
+ export type ErasedDeriver = {
65
+ readonly key: string;
66
+ readonly derive: (req: Request, ctx: never) => unknown;
67
+ };
package/src/dot.ts ADDED
@@ -0,0 +1,282 @@
1
+ /**
2
+ * DOT adapter for `@arki/http` — the `http()` pip.
3
+ *
4
+ * Mount it LAST. Feature pips publish {@link RouteBundle}s as token
5
+ * services in their `boot`; `http()` derives its `needs` from the tokens
6
+ * you pass, so the app builder's wiring guard enforces — at compile time —
7
+ * that every listed bundle is provided by an earlier pip:
8
+ *
9
+ * ```ts
10
+ * import { defineApp } from '@arki/dot';
11
+ * import { http } from '@arki/http/dot';
12
+ *
13
+ * const app = await defineApp('shop-api')
14
+ * .use(db({ url }))
15
+ * .use(ordersPip) // publishes OrdersRoutes
16
+ * .use(billingPip) // publishes BillingRoutes
17
+ * .use(http({ port: 3000, bundles: [OrdersRoutes, BillingRoutes] }))
18
+ * .start();
19
+ * ```
20
+ *
21
+ * Lifecycle mapping — mounting last is what makes the drain order right:
22
+ *
23
+ * - `boot` — collect bundles, build the router, publish `httpServer`.
24
+ * NOT listening yet; all feature boots finish first.
25
+ * - `start` — listen (first among starts — the route table is final).
26
+ * - `stop` — stop accepting, drain in-flight handlers
27
+ * (`drainTimeoutMs`). Reverse order puts this FIRST:
28
+ * ingress stops before feature pips tear down.
29
+ * - `dispose` — sever remaining connections (open SSE streams end here)
30
+ * and release the port.
31
+ *
32
+ * `@arki/dot` is an OPTIONAL peer of `@arki/http`. Importing this adapter
33
+ * without `@arki/dot` installed fails at module load — intentional: the
34
+ * adapter only makes sense inside a DOT app.
35
+ */
36
+
37
+ import type { DotConfigureContext, EmptyShape, Pip, Token } from '@arki/dot/pip';
38
+ import { DotPipError, pip } from '@arki/dot/pip';
39
+
40
+ import type { RouteBundle } from './bundle.js';
41
+ import type { ContractLike } from './contract.js';
42
+ import type { HttpMiddleware } from './engine.js';
43
+ import type { ListenHandle } from './listen.js';
44
+ import { buildEngine, composeMiddleware } from './engine.js';
45
+ import { HTTP_ERROR_CODES, HttpConfigError } from './error.js';
46
+ import { listen } from './listen.js';
47
+ import { contractMeta } from './openapi.js';
48
+
49
+ /** A token publishing a {@link RouteBundle} — what `options.bundles` lists. */
50
+ export type BundleToken = Token<RouteBundle, string>;
51
+
52
+ /**
53
+ * The service the `http()` pip publishes. `fetch` is the composed handler
54
+ * — also the unit-test seam: call the app without a socket. `port`/`url`
55
+ * are defined once the app has started.
56
+ */
57
+ export type HttpServer = {
58
+ readonly fetch: (req: Request) => Promise<Response>;
59
+ port(): number | undefined;
60
+ url(): string | undefined;
61
+ /** Requests currently being handled (streams count until their handler returns). */
62
+ inflight(): number;
63
+ };
64
+
65
+ /** Services published by the http adapter. */
66
+ export type HttpServices = {
67
+ readonly httpServer: HttpServer;
68
+ };
69
+
70
+ export type HttpOptions<TBundles extends readonly BundleToken[]> = {
71
+ /** Port to listen on in `start`. `0` picks an ephemeral port. */
72
+ readonly port: number;
73
+ readonly hostname?: string;
74
+ /**
75
+ * Bundle tokens to serve, in route-table order. Each becomes a `needs`
76
+ * entry — the wiring guard rejects the composition unless an earlier
77
+ * pip publishes it.
78
+ */
79
+ readonly bundles: TBundles;
80
+ /**
81
+ * App-wide fetch-shaped middleware (first = outermost): CORS, request
82
+ * logging, compression. For per-request values use bundle derivers —
83
+ * they are typed into handler contexts; middleware is not.
84
+ */
85
+ readonly middleware?: readonly HttpMiddleware[];
86
+ /** Milliseconds `stop` waits for in-flight requests. Default 10 000. */
87
+ readonly drainTimeoutMs?: number;
88
+ };
89
+
90
+ /** Wire-needs record derived from the bundle tokens. */
91
+ export type BundleNeeds<TBundles extends readonly BundleToken[]> = {
92
+ readonly [Tok in TBundles[number] as Tok extends Token<RouteBundle, infer K> ? K : never]: RouteBundle;
93
+ };
94
+
95
+ /**
96
+ * Stable error codes thrown by the http pip beyond the engine's own.
97
+ * Exported so consumers and coding agents can match against them.
98
+ */
99
+ export const HTTP_PIP_ERROR_CODES = {
100
+ /** A bundle token's service was missing from the boot context (erased composition only). */
101
+ bundleMissing: 'ARKI_HTTP_E008',
102
+ } as const;
103
+
104
+ const DEFAULT_DRAIN_TIMEOUT_MS = 10_000;
105
+
106
+ type PipState = {
107
+ tracked: ((req: Request) => Promise<Response>) | undefined;
108
+ handle: ListenHandle | undefined;
109
+ };
110
+
111
+ type InflightGauge = {
112
+ count: number;
113
+ waiters: (() => void)[];
114
+ };
115
+
116
+ function drained(gauge: InflightGauge, timeoutMs: number): Promise<void> {
117
+ return new Promise((resolve, reject) => {
118
+ if (gauge.count === 0) {
119
+ resolve();
120
+ return;
121
+ }
122
+ const timer = setTimeout(() => {
123
+ reject(new Error(`drain timeout after ${String(timeoutMs)}ms`));
124
+ }, timeoutMs);
125
+ timer.unref();
126
+ gauge.waiters.push(() => {
127
+ clearTimeout(timer);
128
+ resolve();
129
+ });
130
+ });
131
+ }
132
+
133
+ function displayHost(hostname: string): string {
134
+ return hostname === '0.0.0.0' || hostname === '::' ? 'localhost' : hostname;
135
+ }
136
+
137
+ /**
138
+ * Build the DOT pip that serves the given bundles. See the module docs
139
+ * for the composition pattern and lifecycle mapping.
140
+ */
141
+ export function http<const TBundles extends readonly BundleToken[]>(
142
+ options: HttpOptions<TBundles>,
143
+ ): Pip<BundleNeeds<TBundles>, HttpServices> {
144
+ const needs: Record<string, BundleToken> = {};
145
+ for (const tok of options.bundles) {
146
+ if (needs[tok.key] !== undefined) {
147
+ throw new HttpConfigError({
148
+ code: HTTP_ERROR_CODES.duplicateRoute,
149
+ message: `[http] bundle token "${tok.key}" is listed twice in options.bundles.`,
150
+ remediation: 'List each bundle token once. To mount one bundle at two places, rename the publishing pip.',
151
+ });
152
+ }
153
+ needs[tok.key] = tok;
154
+ }
155
+
156
+ const state: PipState = { tracked: undefined, handle: undefined };
157
+ const gauge: InflightGauge = { count: 0, waiters: [] };
158
+
159
+ const inner = pip<EmptyShape, HttpServices>({
160
+ name: 'http',
161
+ version: '0.0.1',
162
+ // Compile-time/runtime seam: the precise needs shape is derived from
163
+ // `options.bundles` in the return-type cast below; here the runtime
164
+ // record rides in under the EmptyShape default. The kernel re-checks
165
+ // satisfaction at boot (DOT_LIFECYCLE_E012) for erased composition.
166
+ needs,
167
+ configure(ctx) {
168
+ ctx.registerService('httpServer', 'custom');
169
+ },
170
+ boot(ctx) {
171
+ // Erasure boundary: needs were declared dynamically above, so the
172
+ // typed hook context can't know the bundle keys — the tokens do.
173
+ const record = ctx as unknown as Readonly<Record<string, RouteBundle | undefined>>;
174
+ const bundles: RouteBundle[] = [];
175
+ for (const tok of options.bundles) {
176
+ const bundle = record[tok.key];
177
+ if (bundle === undefined) {
178
+ throw new DotPipError({
179
+ code: HTTP_PIP_ERROR_CODES.bundleMissing,
180
+ message: `[http] bundle "${tok.key}" was not provided by any earlier pip.`,
181
+ remediation:
182
+ 'Mount the pip that publishes this bundle before http(...). The typed builder enforces this; erased composition reaches this check.',
183
+ docsUrl: 'https://arki.dev/http/errors/arki-http-e008',
184
+ });
185
+ }
186
+ bundles.push(bundle);
187
+ }
188
+
189
+ const engine = buildEngine(bundles);
190
+ const composed = composeMiddleware(engine.fetch, options.middleware ?? []);
191
+ const tracked = async (req: Request): Promise<Response> => {
192
+ gauge.count += 1;
193
+ try {
194
+ return await composed(req);
195
+ } finally {
196
+ gauge.count -= 1;
197
+ if (gauge.count === 0) {
198
+ const waiters = gauge.waiters.splice(0);
199
+ for (const waiter of waiters) waiter();
200
+ }
201
+ }
202
+ };
203
+ state.tracked = tracked;
204
+
205
+ const httpServer: HttpServer = {
206
+ fetch: tracked,
207
+ port: () => state.handle?.port,
208
+ url: () => {
209
+ const handle = state.handle;
210
+ return handle === undefined
211
+ ? undefined
212
+ : `http://${displayHost(handle.hostname)}:${String(handle.port)}`;
213
+ },
214
+ inflight: () => gauge.count,
215
+ };
216
+ return { httpServer };
217
+ },
218
+ async start() {
219
+ const tracked = state.tracked;
220
+ if (tracked === undefined) {
221
+ throw new DotPipError({
222
+ code: HTTP_PIP_ERROR_CODES.bundleMissing,
223
+ message: '[http] start ran without a booted engine — kernel contract violation.',
224
+ remediation: 'This should be unreachable; boot always precedes start. Report it.',
225
+ docsUrl: 'https://arki.dev/http/errors/arki-http-e008',
226
+ });
227
+ }
228
+ state.handle = await listen(tracked, {
229
+ port: options.port,
230
+ ...(options.hostname === undefined ? {} : { hostname: options.hostname }),
231
+ });
232
+ },
233
+ async stop() {
234
+ const handle = state.handle;
235
+ if (handle === undefined) return;
236
+ handle.stopAccepting();
237
+ const timeoutMs = options.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS;
238
+ try {
239
+ await drained(gauge, timeoutMs);
240
+ state.handle = undefined;
241
+ } catch {
242
+ const remaining = gauge.count;
243
+ handle.forceClose();
244
+ state.handle = undefined;
245
+ throw new DotPipError({
246
+ code: HTTP_ERROR_CODES.drainTimeout,
247
+ message: `[http] ${String(remaining)} request(s) still in flight after the ${String(timeoutMs)}ms drain budget — connections were severed.`,
248
+ remediation:
249
+ 'Long-running handlers or streams outlived the drain budget. Raise drainTimeoutMs on http(...), or make handlers respect ctx.signal.',
250
+ docsUrl: 'https://arki.dev/http/errors/arki-http-e005',
251
+ });
252
+ }
253
+ },
254
+ dispose() {
255
+ if (state.handle !== undefined) {
256
+ state.handle.forceClose();
257
+ state.handle = undefined;
258
+ }
259
+ },
260
+ });
261
+
262
+ // Erasure seam: `inner` is Pip<{}, HttpServices> because the needs shape
263
+ // was runtime-built; the cast re-attaches the token-derived needs so the
264
+ // app builder's guard checks bundle provision. Same seam as rename()/
265
+ // testPip() in the kernel.
266
+ return inner as unknown as Pip<BundleNeeds<TBundles>, HttpServices>;
267
+ }
268
+
269
+ /**
270
+ * Register contracts in the DOT manifest from a feature pip's `configure`
271
+ * hook. Converts zod schemas to JSON Schema once, at configure time, so
272
+ * `dot explain --openapi` renders without booting:
273
+ *
274
+ * ```ts
275
+ * configure(ctx) {
276
+ * registerRoutes(ctx, [listOrders, createOrder, progress]);
277
+ * }
278
+ * ```
279
+ */
280
+ export function registerRoutes(ctx: DotConfigureContext, contracts: readonly ContractLike[]): void {
281
+ for (const contract of contracts) ctx.registerRoute(contractMeta(contract));
282
+ }