@beignet/core 0.0.34 → 0.0.36

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 (120) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +145 -15
  3. package/dist/agent-capabilities/index.d.ts +197 -0
  4. package/dist/agent-capabilities/index.d.ts.map +1 -0
  5. package/dist/agent-capabilities/index.js +282 -0
  6. package/dist/agent-capabilities/index.js.map +1 -0
  7. package/dist/application/index.d.ts +2 -2
  8. package/dist/application/index.d.ts.map +1 -1
  9. package/dist/application/index.js +13 -3
  10. package/dist/application/index.js.map +1 -1
  11. package/dist/client/client.d.ts +2 -0
  12. package/dist/client/client.d.ts.map +1 -1
  13. package/dist/client/client.js +33 -16
  14. package/dist/client/client.js.map +1 -1
  15. package/dist/contracts/contract-builder.d.ts +5 -3
  16. package/dist/contracts/contract-builder.d.ts.map +1 -1
  17. package/dist/contracts/contract-builder.js +3 -12
  18. package/dist/contracts/contract-builder.js.map +1 -1
  19. package/dist/contracts/contract-group.d.ts +2 -2
  20. package/dist/contracts/contract-group.d.ts.map +1 -1
  21. package/dist/contracts/contract-group.js +2 -1
  22. package/dist/contracts/contract-group.js.map +1 -1
  23. package/dist/contracts/metadata.d.ts +3 -0
  24. package/dist/contracts/metadata.d.ts.map +1 -0
  25. package/dist/contracts/metadata.js +14 -0
  26. package/dist/contracts/metadata.js.map +1 -0
  27. package/dist/contracts/types.d.ts +20 -0
  28. package/dist/contracts/types.d.ts.map +1 -1
  29. package/dist/contracts/types.js.map +1 -1
  30. package/dist/events/index.d.ts +4 -4
  31. package/dist/events/index.d.ts.map +1 -1
  32. package/dist/events/index.js +5 -5
  33. package/dist/events/index.js.map +1 -1
  34. package/dist/idempotency/index.d.ts.map +1 -1
  35. package/dist/idempotency/index.js +14 -9
  36. package/dist/idempotency/index.js.map +1 -1
  37. package/dist/jobs/index.d.ts.map +1 -1
  38. package/dist/jobs/index.js +10 -4
  39. package/dist/jobs/index.js.map +1 -1
  40. package/dist/openapi/index.js +15 -8
  41. package/dist/openapi/index.js.map +1 -1
  42. package/dist/ports/auth.d.ts +2 -0
  43. package/dist/ports/auth.d.ts.map +1 -1
  44. package/dist/ports/auth.js +2 -0
  45. package/dist/ports/auth.js.map +1 -1
  46. package/dist/providers/index.d.ts +1 -1
  47. package/dist/providers/index.d.ts.map +1 -1
  48. package/dist/providers/index.js.map +1 -1
  49. package/dist/providers/provider.d.ts +9 -1
  50. package/dist/providers/provider.d.ts.map +1 -1
  51. package/dist/providers/provider.js.map +1 -1
  52. package/dist/schedules/index.d.ts +6 -13
  53. package/dist/schedules/index.d.ts.map +1 -1
  54. package/dist/schedules/index.js +24 -44
  55. package/dist/schedules/index.js.map +1 -1
  56. package/dist/server/index.d.ts +2 -2
  57. package/dist/server/index.d.ts.map +1 -1
  58. package/dist/server/index.js +1 -1
  59. package/dist/server/index.js.map +1 -1
  60. package/dist/server/request-executor.d.ts +72 -0
  61. package/dist/server/request-executor.d.ts.map +1 -0
  62. package/dist/server/request-executor.js +781 -0
  63. package/dist/server/request-executor.js.map +1 -0
  64. package/dist/server/request-preparation.d.ts +39 -0
  65. package/dist/server/request-preparation.d.ts.map +1 -0
  66. package/dist/server/request-preparation.js +248 -0
  67. package/dist/server/request-preparation.js.map +1 -0
  68. package/dist/server/response-finalization.d.ts +34 -0
  69. package/dist/server/response-finalization.d.ts.map +1 -0
  70. package/dist/server/response-finalization.js +279 -0
  71. package/dist/server/response-finalization.js.map +1 -0
  72. package/dist/server/route-definitions.d.ts +139 -0
  73. package/dist/server/route-definitions.d.ts.map +1 -0
  74. package/dist/server/route-definitions.js +94 -0
  75. package/dist/server/route-definitions.js.map +1 -0
  76. package/dist/server/route-matching.d.ts +15 -0
  77. package/dist/server/route-matching.d.ts.map +1 -0
  78. package/dist/server/route-matching.js +46 -0
  79. package/dist/server/route-matching.js.map +1 -0
  80. package/dist/server/server.d.ts +6 -147
  81. package/dist/server/server.d.ts.map +1 -1
  82. package/dist/server/server.js +29 -1350
  83. package/dist/server/server.js.map +1 -1
  84. package/dist/server/use-case-route.d.ts +1 -1
  85. package/dist/tracing/index.d.ts +1 -1
  86. package/dist/tracing/index.d.ts.map +1 -1
  87. package/dist/tracing/index.js.map +1 -1
  88. package/dist/uploads/client.js +4 -7
  89. package/dist/uploads/client.js.map +1 -1
  90. package/dist/uploads/index.d.ts.map +1 -1
  91. package/dist/uploads/index.js +59 -12
  92. package/dist/uploads/index.js.map +1 -1
  93. package/package.json +5 -1
  94. package/skills/app-architecture/SKILL.md +27 -1
  95. package/src/agent-capabilities/index.ts +643 -0
  96. package/src/application/index.ts +19 -5
  97. package/src/client/client.ts +41 -17
  98. package/src/contracts/contract-builder.ts +8 -16
  99. package/src/contracts/contract-group.ts +4 -6
  100. package/src/contracts/metadata.ts +20 -0
  101. package/src/contracts/types.ts +39 -0
  102. package/src/events/index.ts +16 -20
  103. package/src/idempotency/index.ts +17 -9
  104. package/src/jobs/index.ts +8 -3
  105. package/src/openapi/index.ts +24 -12
  106. package/src/ports/auth.ts +2 -0
  107. package/src/providers/index.ts +1 -0
  108. package/src/providers/provider.ts +17 -2
  109. package/src/schedules/index.ts +57 -63
  110. package/src/server/index.ts +4 -2
  111. package/src/server/request-executor.ts +1170 -0
  112. package/src/server/request-preparation.ts +409 -0
  113. package/src/server/response-finalization.ts +385 -0
  114. package/src/server/route-definitions.ts +338 -0
  115. package/src/server/route-matching.ts +64 -0
  116. package/src/server/server.ts +63 -2139
  117. package/src/server/use-case-route.ts +1 -1
  118. package/src/tracing/index.ts +1 -0
  119. package/src/uploads/client.ts +4 -7
  120. package/src/uploads/index.ts +70 -12
@@ -0,0 +1,1170 @@
1
+ import type { HttpContractConfig } from "../contracts/index.js";
2
+ import {
3
+ httpErrors,
4
+ isAppError,
5
+ toErrorResponseBody,
6
+ } from "../errors/index.js";
7
+ import {
8
+ IdempotencyConflictError,
9
+ IdempotencyInProgressError,
10
+ } from "../idempotency/index.js";
11
+ import {
12
+ type MemoInstrumentationEvent,
13
+ runWithMemoScope,
14
+ } from "../memo/index.js";
15
+ import type { AnyPorts } from "../ports/index.js";
16
+ import {
17
+ AuthUnauthorizedError,
18
+ EntitlementRequiredError,
19
+ GateAuthorizationError,
20
+ TenantRequiredError,
21
+ } from "../ports/index.js";
22
+ import {
23
+ createProviderInstrumentation,
24
+ type ProviderInstrumentationTarget,
25
+ resolveProviderInstrumentationPort,
26
+ } from "../providers/index.js";
27
+ import {
28
+ parseTraceparent,
29
+ resolveTracingPort,
30
+ runWithTracing,
31
+ type TraceContextInput,
32
+ } from "../tracing/index.js";
33
+ import type { ContextSeed } from "./context.js";
34
+ import type {
35
+ Handler,
36
+ HandlerArgs,
37
+ HttpRequestLike,
38
+ HttpResponse,
39
+ RequestStageTimings,
40
+ RouteHook,
41
+ ServerCaughtErrorHook,
42
+ ServerHook,
43
+ ServerUnhandledErrorMapper,
44
+ } from "./http.js";
45
+ import type { ServerInstrumentationOptions } from "./instrumentation.js";
46
+ import {
47
+ getResponseFinalizerHook,
48
+ type ResponseFinalizerValidationState,
49
+ } from "./internal-hooks.js";
50
+ import {
51
+ readContextActor,
52
+ readContextTenant,
53
+ setActiveRequestIdentity,
54
+ } from "./request-context.js";
55
+ import {
56
+ prepareRequestInputs,
57
+ type RequestBodyOptions,
58
+ requestBodyLimit,
59
+ requestHeadersToRecord,
60
+ } from "./request-preparation.js";
61
+ import {
62
+ defaultErrorResponse,
63
+ errorResponse,
64
+ finalizeResponse,
65
+ isHttpResponseLike,
66
+ isWebResponse,
67
+ mergeNativeResponseHeaders,
68
+ normalizeHttpResponse,
69
+ normalizeResponse,
70
+ ResponseContractViolationError,
71
+ type ResponseOwner,
72
+ responseForHooks,
73
+ responseOwnerFor,
74
+ toContractViolationResponse,
75
+ withFrameworkErrorOwnerHeader,
76
+ } from "./response-finalization.js";
77
+ import {
78
+ type CompiledPath,
79
+ compilePath,
80
+ decodeMatchedParams,
81
+ PathDecodeError,
82
+ } from "./route-matching.js";
83
+
84
+ function withoutHeadResponseBody(
85
+ response: HttpResponse,
86
+ requestMethod: string,
87
+ ): HttpResponse {
88
+ if (requestMethod.toUpperCase() !== "HEAD") return response;
89
+
90
+ if (isWebResponse(response)) {
91
+ if (response.body === null) return response;
92
+ void response.body.cancel().catch(() => {});
93
+ return new Response(null, {
94
+ status: response.status,
95
+ statusText: response.statusText,
96
+ headers: response.headers,
97
+ });
98
+ }
99
+
100
+ const { body: _body, ...headResponse } = response;
101
+ return headResponse;
102
+ }
103
+
104
+ type RequestExecutorOptions<Ctx> = {
105
+ instrumentation?: ServerInstrumentationOptions<Ctx> | false;
106
+ validateResponses?: boolean;
107
+ requestBody?: RequestBodyOptions;
108
+ onCaughtError?: ServerCaughtErrorHook<Ctx>;
109
+ mapUnhandledError?: ServerUnhandledErrorMapper<Ctx>;
110
+ };
111
+
112
+ type ExecutionResult<Ctx> = {
113
+ ctx?: Ctx;
114
+ response: HttpResponse;
115
+ error?: unknown;
116
+ owner?: ResponseOwner;
117
+ };
118
+
119
+ type ExecutionTarget<Ctx, C extends HttpContractConfig> = {
120
+ contract: C;
121
+ /**
122
+ * Compiled route pattern. Only required when the executor is invoked
123
+ * without pre-matched params.
124
+ */
125
+ compiled?: CompiledPath;
126
+ handler: Handler<Ctx, C>;
127
+ /**
128
+ * Success status whose response schema validation is skipped because the
129
+ * use case bound to this route already validated its output against the
130
+ * same schema object.
131
+ */
132
+ responseValidationExemptStatus?: number;
133
+ };
134
+
135
+ /**
136
+ * Build the per-request execution pipeline once.
137
+ *
138
+ * The returned executor takes the contract, compiled pattern, and user handler
139
+ * per invocation so fallback responses (404/405) can reuse a single pipeline
140
+ * across requests instead of rebuilding it per unmatched request.
141
+ */
142
+ export function createRequestExecutor<
143
+ Ctx,
144
+ FinalPorts extends AnyPorts,
145
+ C extends HttpContractConfig,
146
+ >(
147
+ options: RequestExecutorOptions<Ctx>,
148
+ finalPorts: FinalPorts,
149
+ contextRuntime: {
150
+ createRequestContext: (
151
+ req: HttpRequestLike,
152
+ contract: HttpContractConfig,
153
+ ) => Promise<Ctx>;
154
+ finalizeContext: (seed: ContextSeed<Ctx> | Ctx) => Ctx;
155
+ },
156
+ hooks: ServerHook<Ctx, FinalPorts>[],
157
+ routeHooks: readonly RouteHook<unknown, object>[] = [],
158
+ optionsOverrides?: {
159
+ skipRoutePreparation?: boolean;
160
+ /**
161
+ * Run the route through the full hook pipeline without contract
162
+ * preparation: no query/path/header/body parsing or validation, and the
163
+ * request body is left unconsumed for the handler.
164
+ */
165
+ rawRoute?: boolean;
166
+ },
167
+ ): (
168
+ target: ExecutionTarget<Ctx, C>,
169
+ req: HttpRequestLike,
170
+ preMatchedParams?: Record<string, string>,
171
+ ) => Promise<HttpResponse> {
172
+ const warnedNativeReplacementHooks = new WeakSet<object>();
173
+ const maxRequestBodyBytes = requestBodyLimit(options.requestBody);
174
+
175
+ const executeRequest = async (
176
+ target: ExecutionTarget<Ctx, C>,
177
+ req: HttpRequestLike,
178
+ preMatchedParams?: Record<string, string>,
179
+ ) => {
180
+ const { contract, handler: userHandler } = target;
181
+ let baseCtx: Ctx | undefined;
182
+ let pathValue: HandlerArgs<Ctx, C>["path"] | undefined;
183
+ let queryValue: HandlerArgs<Ctx, C>["query"] | undefined;
184
+ let headersValue: HandlerArgs<Ctx, C>["headers"] | undefined;
185
+ let bodyValue: HandlerArgs<Ctx, C>["body"] | undefined;
186
+ const startedAt = Date.now();
187
+ // Stage timings accumulate because retries re-enter the send phase and
188
+ // beforeHandle spans the route-hook and server-hook loops.
189
+ const stages: RequestStageTimings = {
190
+ onRequestMs: 0,
191
+ parseMs: 0,
192
+ contextMs: 0,
193
+ beforeHandleMs: 0,
194
+ handlerMs: 0,
195
+ sendMs: 0,
196
+ };
197
+ const timeStage = async <T>(
198
+ stage: keyof RequestStageTimings,
199
+ run: () => Promise<T> | T,
200
+ ): Promise<T> => {
201
+ const stageStartedAt = performance.now();
202
+ try {
203
+ return await run();
204
+ } finally {
205
+ stages[stage] += performance.now() - stageStartedAt;
206
+ }
207
+ };
208
+
209
+ const resolveErrorResult = async (
210
+ error: unknown,
211
+ ctx?: Ctx,
212
+ path?: HandlerArgs<Ctx, C>["path"],
213
+ query?: HandlerArgs<Ctx, C>["query"],
214
+ headers?: HandlerArgs<Ctx, C>["headers"],
215
+ body?: HandlerArgs<Ctx, C>["body"],
216
+ resultOptions?: {
217
+ owner?: ResponseOwner;
218
+ },
219
+ ): Promise<ExecutionResult<Ctx>> => {
220
+ let currentError = error;
221
+
222
+ const notifyCaughtError = async (caught: unknown) => {
223
+ const args = {
224
+ err: caught,
225
+ req,
226
+ ctx,
227
+ contract,
228
+ path,
229
+ query,
230
+ headers,
231
+ body,
232
+ };
233
+ for (const hook of hooks) {
234
+ if (!hook.onCaughtError) continue;
235
+ try {
236
+ await hook.onCaughtError(args);
237
+ } catch {
238
+ // Observers must not change response behavior.
239
+ }
240
+ }
241
+ if (options.onCaughtError) {
242
+ try {
243
+ await options.onCaughtError(args);
244
+ } catch {
245
+ // Observers must not change response behavior.
246
+ }
247
+ }
248
+ };
249
+
250
+ await notifyCaughtError(currentError);
251
+
252
+ if (currentError instanceof ResponseContractViolationError) {
253
+ return {
254
+ ctx,
255
+ response: toContractViolationResponse(currentError),
256
+ error: currentError,
257
+ owner: "framework",
258
+ };
259
+ }
260
+
261
+ if (isAppError(currentError)) {
262
+ return {
263
+ ctx,
264
+ response: {
265
+ status: currentError.status,
266
+ ...(currentError.headers
267
+ ? { headers: { ...currentError.headers } }
268
+ : {}),
269
+ body: toErrorResponseBody(currentError),
270
+ },
271
+ error: currentError,
272
+ owner: resultOptions?.owner ?? "route",
273
+ };
274
+ }
275
+
276
+ if (currentError instanceof AuthUnauthorizedError) {
277
+ return {
278
+ ctx,
279
+ response: errorResponse(401, currentError.code, currentError.message),
280
+ error: currentError,
281
+ owner: "framework",
282
+ };
283
+ }
284
+
285
+ if (currentError instanceof TenantRequiredError) {
286
+ return {
287
+ ctx,
288
+ response: errorResponse(
289
+ currentError.status,
290
+ currentError.code,
291
+ currentError.message,
292
+ ),
293
+ error: currentError,
294
+ owner: "framework",
295
+ };
296
+ }
297
+
298
+ if (currentError instanceof IdempotencyConflictError) {
299
+ return {
300
+ ctx,
301
+ response: errorResponse(
302
+ httpErrors.IdempotencyConflict.status,
303
+ httpErrors.IdempotencyConflict.code,
304
+ currentError.message,
305
+ {
306
+ namespace: currentError.namespace,
307
+ key: currentError.key,
308
+ },
309
+ ),
310
+ error: currentError,
311
+ owner: "framework",
312
+ };
313
+ }
314
+
315
+ if (currentError instanceof IdempotencyInProgressError) {
316
+ return {
317
+ ctx,
318
+ response: errorResponse(
319
+ httpErrors.IdempotencyInProgress.status,
320
+ httpErrors.IdempotencyInProgress.code,
321
+ currentError.message,
322
+ {
323
+ namespace: currentError.namespace,
324
+ key: currentError.key,
325
+ },
326
+ ),
327
+ error: currentError,
328
+ owner: "framework",
329
+ };
330
+ }
331
+
332
+ if (currentError instanceof GateAuthorizationError) {
333
+ return {
334
+ ctx,
335
+ response: errorResponse(
336
+ currentError.status,
337
+ currentError.code,
338
+ currentError.message,
339
+ currentError.details,
340
+ ),
341
+ error: currentError,
342
+ owner: "framework",
343
+ };
344
+ }
345
+
346
+ if (currentError instanceof EntitlementRequiredError) {
347
+ return {
348
+ ctx,
349
+ response: errorResponse(
350
+ currentError.status,
351
+ currentError.code,
352
+ currentError.message,
353
+ currentError.details,
354
+ ),
355
+ error: currentError,
356
+ owner: "framework",
357
+ };
358
+ }
359
+
360
+ for (const hook of hooks) {
361
+ if (!hook.mapUnhandledError) continue;
362
+ try {
363
+ const handled = await hook.mapUnhandledError({
364
+ err: currentError,
365
+ req,
366
+ ctx,
367
+ contract,
368
+ path,
369
+ query,
370
+ headers,
371
+ body,
372
+ });
373
+ if (handled) {
374
+ const response = normalizeHttpResponse(handled);
375
+ return {
376
+ ctx,
377
+ response,
378
+ error: currentError,
379
+ owner: responseOwnerFor(response, "framework"),
380
+ };
381
+ }
382
+ } catch (hookError) {
383
+ currentError = hookError;
384
+ await notifyCaughtError(currentError);
385
+ }
386
+ }
387
+
388
+ if (options.mapUnhandledError) {
389
+ try {
390
+ const handled = await options.mapUnhandledError({
391
+ err: currentError,
392
+ req,
393
+ ctx,
394
+ contract,
395
+ path,
396
+ query,
397
+ headers,
398
+ body,
399
+ });
400
+ if (handled) {
401
+ const response = normalizeHttpResponse(handled);
402
+ return {
403
+ ctx,
404
+ response,
405
+ error: currentError,
406
+ owner: responseOwnerFor(response, "framework"),
407
+ };
408
+ }
409
+ } catch (hookError) {
410
+ currentError = hookError;
411
+ await notifyCaughtError(currentError);
412
+ }
413
+ }
414
+
415
+ return {
416
+ ctx,
417
+ response: defaultErrorResponse(currentError, ctx),
418
+ error: currentError,
419
+ owner: "framework",
420
+ };
421
+ };
422
+
423
+ try {
424
+ const url = new URL(req.url);
425
+
426
+ let matchedParams: Record<string, string>;
427
+ if (preMatchedParams) {
428
+ matchedParams = preMatchedParams;
429
+ } else {
430
+ const compiled = target.compiled;
431
+ const match = compiled ? compiled.pattern.exec(url.pathname) : null;
432
+ const contractMethod = contract.method.toUpperCase();
433
+ const requestMethod = req.method.toUpperCase();
434
+ const methodMatches =
435
+ contractMethod === requestMethod ||
436
+ (contractMethod === "GET" && requestMethod === "HEAD");
437
+ if (!compiled || !match || !methodMatches) {
438
+ return errorResponse(404, "NOT_FOUND", "Not found");
439
+ }
440
+ try {
441
+ matchedParams = decodeMatchedParams(compiled.keys, match);
442
+ } catch (error) {
443
+ if (error instanceof PathDecodeError) {
444
+ return errorResponse(400, "INVALID_PATH", "Malformed URL path");
445
+ }
446
+ throw error;
447
+ }
448
+ }
449
+ const rawHeaders = requestHeadersToRecord(req.headers);
450
+
451
+ const runNativeBeforeSend = async (
452
+ initialResult: ExecutionResult<Ctx>,
453
+ nativeResponse: Response,
454
+ ): Promise<Response> => {
455
+ const originalView = responseForHooks(nativeResponse);
456
+ const originalHeaders = originalView.headers ?? {};
457
+ let transformed = originalView;
458
+ for (const hook of hooks) {
459
+ if (!hook.beforeSend) continue;
460
+ const nextResponse = await hook.beforeSend({
461
+ req,
462
+ ctx: initialResult.ctx,
463
+ contract,
464
+ path: pathValue,
465
+ query: queryValue,
466
+ headers: headersValue,
467
+ body: bodyValue,
468
+ response: transformed,
469
+ error: initialResult.error,
470
+ native: true,
471
+ });
472
+ if (nextResponse) {
473
+ if (
474
+ (nextResponse.status !== nativeResponse.status ||
475
+ nextResponse.body !== undefined) &&
476
+ !warnedNativeReplacementHooks.has(hook) &&
477
+ process.env.NODE_ENV !== "production"
478
+ ) {
479
+ warnedNativeReplacementHooks.add(hook);
480
+ console.warn(
481
+ `[beignet] beforeSend hook "${hook.name ?? "(anonymous)"}" returned a replacement status or body for a native Response on ${contract.method} ${contract.path}. Native responses are headers-only in beforeSend; status and body changes are ignored.`,
482
+ );
483
+ }
484
+ transformed = {
485
+ status: nativeResponse.status,
486
+ headers: nextResponse.headers,
487
+ };
488
+ }
489
+ }
490
+ return mergeNativeResponseHeaders(
491
+ nativeResponse,
492
+ originalHeaders,
493
+ transformed.headers ?? {},
494
+ );
495
+ };
496
+
497
+ const applyTransformHooks = async (
498
+ initialResult: ExecutionResult<Ctx>,
499
+ allowRetry: boolean,
500
+ ): Promise<ExecutionResult<Ctx>> => {
501
+ try {
502
+ if (isWebResponse(initialResult.response)) {
503
+ return {
504
+ ...initialResult,
505
+ response: await runNativeBeforeSend(
506
+ initialResult,
507
+ initialResult.response,
508
+ ),
509
+ };
510
+ }
511
+
512
+ let transformed = normalizeResponse(initialResult.response);
513
+ for (const hook of hooks) {
514
+ if (!hook.beforeSend) continue;
515
+ const nextResponse = await hook.beforeSend({
516
+ req,
517
+ ctx: initialResult.ctx,
518
+ contract,
519
+ path: pathValue,
520
+ query: queryValue,
521
+ headers: headersValue,
522
+ body: bodyValue,
523
+ response: transformed,
524
+ error: initialResult.error,
525
+ });
526
+ if (nextResponse) {
527
+ transformed = normalizeResponse(nextResponse);
528
+ }
529
+ }
530
+ return {
531
+ ...initialResult,
532
+ response: transformed,
533
+ };
534
+ } catch (error) {
535
+ const mapped = await resolveErrorResult(
536
+ error,
537
+ initialResult.ctx,
538
+ pathValue,
539
+ queryValue,
540
+ headersValue,
541
+ bodyValue,
542
+ { owner: "framework" },
543
+ );
544
+ if (!allowRetry) {
545
+ return mapped;
546
+ }
547
+ return applyTransformHooks(mapped, false);
548
+ }
549
+ };
550
+
551
+ const applyResponseFinalizerHooks = async (
552
+ initialResult: ExecutionResult<Ctx>,
553
+ allowRetry: boolean,
554
+ responseValidation: ResponseFinalizerValidationState,
555
+ ): Promise<ExecutionResult<Ctx>> => {
556
+ const response = normalizeHttpResponse(initialResult.response);
557
+ const native = isWebResponse(response);
558
+ const owner = responseOwnerFor(response, initialResult.owner);
559
+
560
+ try {
561
+ for (const hook of hooks) {
562
+ const finalizer = getResponseFinalizerHook<Ctx>(hook);
563
+ if (!finalizer) continue;
564
+ await finalizer({
565
+ req,
566
+ ctx: initialResult.ctx,
567
+ contract,
568
+ path: pathValue,
569
+ query: queryValue,
570
+ headers: headersValue,
571
+ body: bodyValue,
572
+ response: responseForHooks(response),
573
+ error: initialResult.error,
574
+ native: native ? true : undefined,
575
+ owner,
576
+ responseValidation,
577
+ });
578
+ }
579
+
580
+ return {
581
+ ...initialResult,
582
+ response,
583
+ };
584
+ } catch (error) {
585
+ const mapped = await resolveErrorResult(
586
+ error,
587
+ initialResult.ctx,
588
+ pathValue,
589
+ queryValue,
590
+ headersValue,
591
+ bodyValue,
592
+ { owner: "framework" },
593
+ );
594
+ if (!allowRetry) {
595
+ return mapped;
596
+ }
597
+ const transformed = await applyTransformHooks(mapped, true);
598
+ return applyResponseFinalizerHooks(
599
+ transformed,
600
+ false,
601
+ "not-applicable",
602
+ );
603
+ }
604
+ };
605
+
606
+ let result: ExecutionResult<Ctx> | undefined;
607
+
608
+ const onRequestStartedAt = performance.now();
609
+ for (const hook of hooks) {
610
+ if (!hook.onRequest) continue;
611
+ try {
612
+ const hookResult = await hook.onRequest({
613
+ req,
614
+ ports: finalPorts,
615
+ contract,
616
+ params: matchedParams,
617
+ });
618
+ if (hookResult) {
619
+ const response = normalizeHttpResponse(hookResult);
620
+ result = {
621
+ response,
622
+ owner: responseOwnerFor(response, "framework"),
623
+ };
624
+ break;
625
+ }
626
+ } catch (error) {
627
+ result = await resolveErrorResult(
628
+ error,
629
+ undefined,
630
+ undefined,
631
+ undefined,
632
+ undefined,
633
+ undefined,
634
+ { owner: "framework" },
635
+ );
636
+ break;
637
+ }
638
+ }
639
+ stages.onRequestMs = performance.now() - onRequestStartedAt;
640
+
641
+ if (!result) {
642
+ if (optionsOverrides?.skipRoutePreparation) {
643
+ let createdCtx!: Ctx;
644
+ try {
645
+ createdCtx = await timeStage("contextMs", () =>
646
+ contextRuntime.createRequestContext(req, contract),
647
+ );
648
+ baseCtx = createdCtx;
649
+ } catch (error) {
650
+ result = await resolveErrorResult(
651
+ error,
652
+ undefined,
653
+ undefined,
654
+ undefined,
655
+ undefined,
656
+ undefined,
657
+ { owner: "framework" },
658
+ );
659
+ }
660
+
661
+ if (!result) {
662
+ try {
663
+ result = {
664
+ ctx: createdCtx,
665
+ response: normalizeHttpResponse(
666
+ await timeStage("handlerMs", () =>
667
+ userHandler({
668
+ req,
669
+ ctx: createdCtx,
670
+ contract,
671
+ path: {} as HandlerArgs<Ctx, C>["path"],
672
+ query: {} as HandlerArgs<Ctx, C>["query"],
673
+ headers: rawHeaders as HandlerArgs<Ctx, C>["headers"],
674
+ body: undefined as HandlerArgs<Ctx, C>["body"],
675
+ }),
676
+ ),
677
+ ),
678
+ owner: "framework",
679
+ };
680
+ } catch (error) {
681
+ result = await resolveErrorResult(
682
+ error,
683
+ createdCtx,
684
+ undefined,
685
+ undefined,
686
+ undefined,
687
+ undefined,
688
+ { owner: "framework" },
689
+ );
690
+ }
691
+ }
692
+ } else {
693
+ const parseStartedAt = performance.now();
694
+ let path = undefined as HandlerArgs<Ctx, C>["path"] | undefined;
695
+ let query = undefined as HandlerArgs<Ctx, C>["query"] | undefined;
696
+ let headers = undefined as HandlerArgs<Ctx, C>["headers"] | undefined;
697
+ let body = undefined as HandlerArgs<Ctx, C>["body"] | undefined;
698
+ const prepared = await prepareRequestInputs({
699
+ contract,
700
+ req,
701
+ url,
702
+ rawHeaders,
703
+ matchedParams,
704
+ maxRequestBodyBytes,
705
+ rawRoute: optionsOverrides?.rawRoute,
706
+ });
707
+ if (!prepared.ok) {
708
+ result = {
709
+ response: prepared.response,
710
+ owner: "framework",
711
+ };
712
+ } else {
713
+ path = prepared.inputs.path as HandlerArgs<Ctx, C>["path"];
714
+ query = prepared.inputs.query as HandlerArgs<Ctx, C>["query"];
715
+ headers = prepared.inputs.headers as HandlerArgs<Ctx, C>["headers"];
716
+ body = prepared.inputs.body as HandlerArgs<Ctx, C>["body"];
717
+ }
718
+ stages.parseMs = performance.now() - parseStartedAt;
719
+
720
+ if (!result) {
721
+ pathValue = path;
722
+ queryValue = query;
723
+ headersValue = headers;
724
+ bodyValue = body;
725
+
726
+ let createdCtx!: Ctx;
727
+ try {
728
+ createdCtx = await timeStage("contextMs", () =>
729
+ contextRuntime.createRequestContext(req, contract),
730
+ );
731
+ baseCtx = createdCtx;
732
+ } catch (error) {
733
+ result = await resolveErrorResult(
734
+ error,
735
+ undefined,
736
+ pathValue,
737
+ queryValue,
738
+ headersValue,
739
+ bodyValue,
740
+ { owner: "framework" },
741
+ );
742
+ }
743
+
744
+ if (!result) {
745
+ const baseArgs: HandlerArgs<Ctx, C> = {
746
+ req,
747
+ ctx: createdCtx,
748
+ contract,
749
+ path: path as HandlerArgs<Ctx, C>["path"],
750
+ query: query as HandlerArgs<Ctx, C>["query"],
751
+ headers: headers as HandlerArgs<Ctx, C>["headers"],
752
+ body: body as HandlerArgs<Ctx, C>["body"],
753
+ };
754
+
755
+ let currentCtx = createdCtx;
756
+ const beforeHandleStartedAt = performance.now();
757
+ for (const hook of routeHooks) {
758
+ try {
759
+ const additions = await hook.resolve({
760
+ req,
761
+ ctx: currentCtx,
762
+ contract,
763
+ path,
764
+ query,
765
+ headers,
766
+ body,
767
+ });
768
+ if (additions && typeof additions === "object") {
769
+ currentCtx = contextRuntime.finalizeContext({
770
+ ...(currentCtx as object),
771
+ ...additions,
772
+ } as Ctx);
773
+ }
774
+ } catch (error) {
775
+ result = await resolveErrorResult(
776
+ error,
777
+ currentCtx,
778
+ pathValue,
779
+ queryValue,
780
+ headersValue,
781
+ bodyValue,
782
+ { owner: "framework" },
783
+ );
784
+ break;
785
+ }
786
+ }
787
+
788
+ if (!result) {
789
+ for (const hook of hooks) {
790
+ if (!hook.beforeHandle) continue;
791
+ try {
792
+ const hookResult = await hook.beforeHandle({
793
+ req,
794
+ ctx: currentCtx,
795
+ contract,
796
+ path,
797
+ query,
798
+ headers,
799
+ body,
800
+ });
801
+ if (isWebResponse(hookResult)) {
802
+ result = {
803
+ ctx: currentCtx,
804
+ response: hookResult,
805
+ owner: "transport",
806
+ };
807
+ break;
808
+ }
809
+ if (isHttpResponseLike(hookResult)) {
810
+ result = {
811
+ ctx: currentCtx,
812
+ response: normalizeResponse(hookResult),
813
+ owner: "framework",
814
+ };
815
+ break;
816
+ }
817
+ if (hookResult?.ctx !== undefined) {
818
+ currentCtx = contextRuntime.finalizeContext(
819
+ hookResult.ctx,
820
+ );
821
+ }
822
+ if (hookResult?.response) {
823
+ const response = normalizeHttpResponse(
824
+ hookResult.response,
825
+ );
826
+ result = {
827
+ ctx: currentCtx,
828
+ response,
829
+ owner: responseOwnerFor(response, "framework"),
830
+ };
831
+ break;
832
+ }
833
+ } catch (error) {
834
+ result = await resolveErrorResult(
835
+ error,
836
+ currentCtx,
837
+ pathValue,
838
+ queryValue,
839
+ headersValue,
840
+ bodyValue,
841
+ { owner: "framework" },
842
+ );
843
+ break;
844
+ }
845
+ }
846
+ }
847
+ stages.beforeHandleMs = performance.now() - beforeHandleStartedAt;
848
+
849
+ if (!result) {
850
+ // Hooks may have elevated the actor or resolved a tenant.
851
+ // Refresh the ambient request context so record-time
852
+ // consumers such as createAmbientAuditLog see the finalized
853
+ // identity.
854
+ setActiveRequestIdentity({
855
+ actor: readContextActor(currentCtx),
856
+ tenant: readContextTenant(currentCtx),
857
+ });
858
+ try {
859
+ result = {
860
+ ctx: currentCtx,
861
+ response: normalizeHttpResponse(
862
+ await timeStage("handlerMs", () =>
863
+ userHandler({ ...baseArgs, ctx: currentCtx }),
864
+ ),
865
+ ),
866
+ };
867
+ } catch (error) {
868
+ result = await resolveErrorResult(
869
+ error,
870
+ currentCtx,
871
+ pathValue,
872
+ queryValue,
873
+ headersValue,
874
+ bodyValue,
875
+ );
876
+ }
877
+ }
878
+ }
879
+ }
880
+ }
881
+ }
882
+
883
+ const sendStartedAt = performance.now();
884
+ result = await applyTransformHooks(result, true);
885
+
886
+ let finalResponse = normalizeHttpResponse(result.response);
887
+ let finalError = result.error;
888
+ let finalOwner = responseOwnerFor(finalResponse, result.owner);
889
+ let responseValidation: ResponseFinalizerValidationState =
890
+ "not-applicable";
891
+ if (
892
+ finalOwner === "route" &&
893
+ !isWebResponse(finalResponse) &&
894
+ !(options.validateResponses ?? true)
895
+ ) {
896
+ responseValidation = "disabled";
897
+ } else if (
898
+ finalOwner === "route" &&
899
+ !isWebResponse(finalResponse) &&
900
+ (options.validateResponses ?? true)
901
+ ) {
902
+ try {
903
+ finalResponse = await finalizeResponse(
904
+ contract,
905
+ finalResponse,
906
+ target.responseValidationExemptStatus,
907
+ );
908
+ result = {
909
+ ...result,
910
+ response: finalResponse,
911
+ };
912
+ responseValidation = "validated";
913
+ } catch (error) {
914
+ if (error instanceof ResponseContractViolationError) {
915
+ result = {
916
+ ctx: result.ctx,
917
+ response: toContractViolationResponse(error),
918
+ error,
919
+ owner: "framework",
920
+ };
921
+ finalResponse = normalizeHttpResponse(result.response);
922
+ finalError = result.error;
923
+ finalOwner = responseOwnerFor(finalResponse, result.owner);
924
+ result = await applyTransformHooks(result, true);
925
+ finalResponse = normalizeHttpResponse(result.response);
926
+ finalError = result.error;
927
+ finalOwner = responseOwnerFor(finalResponse, result.owner);
928
+ responseValidation = "not-applicable";
929
+ } else {
930
+ throw error;
931
+ }
932
+ }
933
+ }
934
+
935
+ result = await applyResponseFinalizerHooks(
936
+ result,
937
+ true,
938
+ responseValidation,
939
+ );
940
+ finalResponse = normalizeHttpResponse(result.response);
941
+ finalError = result.error;
942
+ finalOwner = responseOwnerFor(finalResponse, result.owner);
943
+
944
+ if (!isWebResponse(finalResponse)) {
945
+ finalResponse = withFrameworkErrorOwnerHeader(
946
+ finalResponse,
947
+ finalOwner,
948
+ );
949
+ }
950
+ finalResponse = withoutHeadResponseBody(finalResponse, req.method);
951
+ stages.sendMs = performance.now() - sendStartedAt;
952
+
953
+ const durationMs = Date.now() - startedAt;
954
+ const stageTimings = roundStageTimings(stages);
955
+ for (const hook of hooks) {
956
+ if (!hook.afterSend) continue;
957
+ try {
958
+ await hook.afterSend({
959
+ req,
960
+ ctx: result.ctx,
961
+ contract,
962
+ path: pathValue,
963
+ query: queryValue,
964
+ headers: headersValue,
965
+ body: bodyValue,
966
+ response: responseForHooks(finalResponse),
967
+ error: finalError,
968
+ durationMs,
969
+ stages: stageTimings,
970
+ });
971
+ } catch {
972
+ // Ignore after-response hook failures; they should never change the response.
973
+ }
974
+ }
975
+
976
+ return finalResponse;
977
+ } catch (error) {
978
+ const result = await resolveErrorResult(
979
+ error,
980
+ baseCtx,
981
+ pathValue,
982
+ queryValue,
983
+ headersValue,
984
+ bodyValue,
985
+ {
986
+ owner: "framework",
987
+ },
988
+ );
989
+ const response = withoutHeadResponseBody(
990
+ normalizeHttpResponse(result.response),
991
+ req.method,
992
+ );
993
+ if (isWebResponse(response)) {
994
+ return response;
995
+ }
996
+ return withFrameworkErrorOwnerHeader(
997
+ response,
998
+ responseOwnerFor(response, result.owner),
999
+ );
1000
+ }
1001
+ };
1002
+
1003
+ return async (
1004
+ target: ExecutionTarget<Ctx, C>,
1005
+ req: HttpRequestLike,
1006
+ preMatchedParams?: Record<string, string>,
1007
+ ) => {
1008
+ const tracing = resolveTracingPort(finalPorts);
1009
+ const instrumentationOptions =
1010
+ options.instrumentation === false ? undefined : options.instrumentation;
1011
+ const pathname = (() => {
1012
+ try {
1013
+ return new URL(req.url).pathname;
1014
+ } catch {
1015
+ return req.url;
1016
+ }
1017
+ })();
1018
+ const ignored = (
1019
+ instrumentationOptions?.ignorePaths ?? ["/api/devtools"]
1020
+ ).some((prefix) => {
1021
+ const normalized = prefix.replace(/\/+$/, "");
1022
+ return pathname === normalized || pathname.startsWith(`${normalized}/`);
1023
+ });
1024
+
1025
+ if (!tracing || ignored) {
1026
+ return executeRequest(target, req, preMatchedParams);
1027
+ }
1028
+
1029
+ const traceContextHeader =
1030
+ instrumentationOptions?.traceContextHeader ?? "traceparent";
1031
+ const active = tracing.current();
1032
+ const parsedTraceparent =
1033
+ traceContextHeader === false
1034
+ ? undefined
1035
+ : parseTraceparent(req.headers.get(traceContextHeader));
1036
+ const parent: TraceContextInput | undefined = active
1037
+ ? undefined
1038
+ : !parsedTraceparent
1039
+ ? undefined
1040
+ : {
1041
+ traceparent: parsedTraceparent.traceparent,
1042
+ tracestate: req.headers.get("tracestate") ?? undefined,
1043
+ };
1044
+ const traceAttributes = {
1045
+ "beignet.contract.name": target.contract.name,
1046
+ "http.request.method": req.method.toUpperCase(),
1047
+ "http.route": target.contract.path,
1048
+ } as const;
1049
+
1050
+ return await runWithTracing(
1051
+ tracing,
1052
+ {
1053
+ name: `beignet.request ${target.contract.name}`,
1054
+ type: "request",
1055
+ kind: active ? "internal" : "server",
1056
+ parent,
1057
+ attributes: traceAttributes,
1058
+ metricAttributes: traceAttributes,
1059
+ },
1060
+ async (span) => {
1061
+ const response = await executeRequest(target, req, preMatchedParams);
1062
+ span?.setAttribute("http.response.status_code", response.status);
1063
+ if (response.status >= 500) span?.setStatus("error");
1064
+ return response;
1065
+ },
1066
+ );
1067
+ };
1068
+ }
1069
+
1070
+ /**
1071
+ * Build a memo instrumentation recorder from the app ports, or undefined when
1072
+ * no instrumentation sink is wired.
1073
+ *
1074
+ * Resolved per execution rather than at route-build time because providers
1075
+ * contribute `ports.instrumentation`/`ports.devtools` during setup, after
1076
+ * routes are registered.
1077
+ */
1078
+ export function createMemoScopeRecorder(
1079
+ ports: AnyPorts,
1080
+ ): ((event: MemoInstrumentationEvent) => void) | undefined {
1081
+ const target = ports as ProviderInstrumentationTarget;
1082
+ if (!resolveProviderInstrumentationPort(target)) return undefined;
1083
+
1084
+ const instrumentation = createProviderInstrumentation(target, {
1085
+ providerName: "memo",
1086
+ watcher: "memo",
1087
+ });
1088
+ return (event) => {
1089
+ instrumentation.custom({
1090
+ name: `memo.${event.kind}`,
1091
+ label: event.kind === "hit" ? "Memo hit" : "Memo fill",
1092
+ summary: `Memo ${event.kind} for ${event.memo}`,
1093
+ details: {
1094
+ memo: event.memo,
1095
+ key: event.key,
1096
+ ...(event.durationMs !== undefined
1097
+ ? { durationMs: event.durationMs }
1098
+ : {}),
1099
+ ...(event.failed ? { failed: true } : {}),
1100
+ },
1101
+ });
1102
+ };
1103
+ }
1104
+
1105
+ export function buildHandler<
1106
+ Ctx,
1107
+ FinalPorts extends AnyPorts,
1108
+ C extends HttpContractConfig,
1109
+ >(
1110
+ options: RequestExecutorOptions<Ctx>,
1111
+ finalPorts: FinalPorts,
1112
+ contextRuntime: {
1113
+ createRequestContext: (
1114
+ req: HttpRequestLike,
1115
+ contract: HttpContractConfig,
1116
+ ) => Promise<Ctx>;
1117
+ finalizeContext: (seed: ContextSeed<Ctx> | Ctx) => Ctx;
1118
+ },
1119
+ contract: C,
1120
+ userHandler: Handler<Ctx, C>,
1121
+ hooks: ServerHook<Ctx, FinalPorts>[],
1122
+ routeHooks: readonly RouteHook<unknown, object>[] = [],
1123
+ optionsOverrides?: {
1124
+ skipRoutePreparation?: boolean;
1125
+ /**
1126
+ * Run the route through the full hook pipeline without contract
1127
+ * preparation: no query/path/header/body parsing or validation, and the
1128
+ * request body is left unconsumed for the handler.
1129
+ */
1130
+ rawRoute?: boolean;
1131
+ },
1132
+ responseValidationExemptStatus?: number,
1133
+ ): (
1134
+ req: HttpRequestLike,
1135
+ preMatchedParams?: Record<string, string>,
1136
+ ) => Promise<HttpResponse> {
1137
+ const execute = createRequestExecutor<Ctx, FinalPorts, C>(
1138
+ options,
1139
+ finalPorts,
1140
+ contextRuntime,
1141
+ hooks,
1142
+ routeHooks,
1143
+ optionsOverrides,
1144
+ );
1145
+ const executionTarget: ExecutionTarget<Ctx, C> = {
1146
+ contract,
1147
+ compiled: compilePath(contract.path),
1148
+ handler: userHandler,
1149
+ responseValidationExemptStatus,
1150
+ };
1151
+
1152
+ // Every HTTP execution runs inside a fresh memo scope so createMemo(...)
1153
+ // wrappers dedupe lookups for exactly one request.
1154
+ return (req, preMatchedParams) =>
1155
+ runWithMemoScope({ record: createMemoScopeRecorder(finalPorts) }, () =>
1156
+ execute(executionTarget, req, preMatchedParams),
1157
+ );
1158
+ }
1159
+
1160
+ function roundStageTimings(stages: RequestStageTimings): RequestStageTimings {
1161
+ const round = (value: number) => Math.round(value * 100) / 100;
1162
+ return {
1163
+ onRequestMs: round(stages.onRequestMs),
1164
+ parseMs: round(stages.parseMs),
1165
+ contextMs: round(stages.contextMs),
1166
+ beforeHandleMs: round(stages.beforeHandleMs),
1167
+ handlerMs: round(stages.handlerMs),
1168
+ sendMs: round(stages.sendMs),
1169
+ };
1170
+ }