@beignet/core 0.0.27 → 0.0.28

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.
@@ -53,6 +53,7 @@ import type {
53
53
  HttpRequestLike,
54
54
  HttpResponse,
55
55
  HttpResponseLike,
56
+ RequestStageTimings,
56
57
  ResolvedRoute,
57
58
  RouteHook,
58
59
  ServerCaughtErrorHook,
@@ -61,6 +62,11 @@ import type {
61
62
  } from "./http.js";
62
63
  import type { ServerInstrumentationOptions } from "./instrumentation.js";
63
64
  import { createServerInstrumentation } from "./instrumentation.js";
65
+ import {
66
+ getResponseFinalizerHook,
67
+ type ResponseFinalizerResponseOwner,
68
+ type ResponseFinalizerValidationState,
69
+ } from "./internal-hooks.js";
64
70
  import {
65
71
  loadProviderConfig,
66
72
  parseStandardSchema,
@@ -399,6 +405,30 @@ interface RouteBuilder<Ctx, C extends HttpContractConfig> {
399
405
  ) => (req: HttpRequestLike) => Promise<HttpResponse>;
400
406
  }
401
407
 
408
+ /**
409
+ * Identity and hook metadata for a raw route.
410
+ *
411
+ * Raw routes are mounted by the adapter at their own path, so `method` and
412
+ * `path` describe the route to hooks, instrumentation, and error mapping
413
+ * instead of driving routing. `metadata` feeds metadata-driven hooks exactly
414
+ * like contract metadata does, for example `rateLimit` and `idempotency`.
415
+ */
416
+ export type RawRouteInit = {
417
+ name: string;
418
+ method: HttpContractConfig["method"];
419
+ path: string;
420
+ metadata?: HttpContractConfig["metadata"];
421
+ };
422
+
423
+ /**
424
+ * Builder returned by `server.rawRoute(...)`.
425
+ */
426
+ export interface RawRouteBuilder<Ctx> {
427
+ handle: (
428
+ fn: Handler<Ctx, HttpContractConfig>,
429
+ ) => (req: HttpRequestLike) => Promise<HttpResponse>;
430
+ }
431
+
402
432
  /**
403
433
  * Runtime server object returned by `createServer(...)`.
404
434
  */
@@ -417,6 +447,21 @@ export interface ServerInstance<
417
447
  route: <CLike extends ContractLike>(
418
448
  contractLike: CLike,
419
449
  ) => RouteBuilder<Ctx, ResolveContract<CLike>>;
450
+ /**
451
+ * Build a handler for a route that cannot be a contract — webhooks,
452
+ * third-party auth callbacks, streaming endpoints — that still runs the
453
+ * whole server pipeline: correlation, `onRequest`, route hooks,
454
+ * `beforeHandle`, `beforeSend`, `afterSend`, context creation,
455
+ * instrumentation, and framework error mapping.
456
+ *
457
+ * Request parsing and validation are skipped and the request body is left
458
+ * unconsumed, so the handler owns body reading — for example webhook
459
+ * signature verification over the exact raw bytes. Metadata-driven hooks
460
+ * such as rate limiting and idempotency read `init.metadata`. Raw routes
461
+ * are not added to the route registry; mount the returned handler at the
462
+ * route's own path.
463
+ */
464
+ rawRoute: (init: RawRouteInit) => RawRouteBuilder<Ctx>;
420
465
  /**
421
466
  * Build a fully assembled request context from a framework-neutral request.
422
467
  *
@@ -475,7 +520,7 @@ type ExecutionResult<Ctx> = {
475
520
  owner?: ResponseOwner;
476
521
  };
477
522
 
478
- type ResponseOwner = "route" | "framework" | "transport";
523
+ type ResponseOwner = ResponseFinalizerResponseOwner;
479
524
 
480
525
  type CompiledPath = {
481
526
  keys: string[];
@@ -1125,6 +1170,12 @@ function createRequestExecutor<
1125
1170
  routeHooks: readonly RouteHook<unknown, object>[] = [],
1126
1171
  optionsOverrides?: {
1127
1172
  skipRoutePreparation?: boolean;
1173
+ /**
1174
+ * Run the route through the full hook pipeline without contract
1175
+ * preparation: no query/path/header/body parsing or validation, and the
1176
+ * request body is left unconsumed for the handler.
1177
+ */
1178
+ rawRoute?: boolean;
1128
1179
  },
1129
1180
  ): (
1130
1181
  target: ExecutionTarget<Ctx, C>,
@@ -1146,6 +1197,27 @@ function createRequestExecutor<
1146
1197
  let headersValue: HandlerArgs<Ctx, C>["headers"] | undefined;
1147
1198
  let bodyValue: HandlerArgs<Ctx, C>["body"] | undefined;
1148
1199
  const startedAt = Date.now();
1200
+ // Stage timings accumulate because retries re-enter the send phase and
1201
+ // beforeHandle spans the route-hook and server-hook loops.
1202
+ const stages: RequestStageTimings = {
1203
+ onRequestMs: 0,
1204
+ parseMs: 0,
1205
+ contextMs: 0,
1206
+ beforeHandleMs: 0,
1207
+ handlerMs: 0,
1208
+ sendMs: 0,
1209
+ };
1210
+ const timeStage = async <T>(
1211
+ stage: keyof RequestStageTimings,
1212
+ run: () => Promise<T> | T,
1213
+ ): Promise<T> => {
1214
+ const stageStartedAt = performance.now();
1215
+ try {
1216
+ return await run();
1217
+ } finally {
1218
+ stages[stage] += performance.now() - stageStartedAt;
1219
+ }
1220
+ };
1149
1221
 
1150
1222
  const resolveErrorResult = async (
1151
1223
  error: unknown,
@@ -1485,8 +1557,64 @@ function createRequestExecutor<
1485
1557
  }
1486
1558
  };
1487
1559
 
1560
+ const applyResponseFinalizerHooks = async (
1561
+ initialResult: ExecutionResult<Ctx>,
1562
+ allowRetry: boolean,
1563
+ responseValidation: ResponseFinalizerValidationState,
1564
+ ): Promise<ExecutionResult<Ctx>> => {
1565
+ const response = normalizeHttpResponse(initialResult.response);
1566
+ const native = isWebResponse(response);
1567
+ const owner = responseOwnerFor(response, initialResult.owner);
1568
+
1569
+ try {
1570
+ for (const hook of hooks) {
1571
+ const finalizer = getResponseFinalizerHook<Ctx>(hook);
1572
+ if (!finalizer) continue;
1573
+ await finalizer({
1574
+ req,
1575
+ ctx: initialResult.ctx,
1576
+ contract,
1577
+ path: pathValue,
1578
+ query: queryValue,
1579
+ headers: headersValue,
1580
+ body: bodyValue,
1581
+ response: responseForHooks(response),
1582
+ error: initialResult.error,
1583
+ native: native ? true : undefined,
1584
+ owner,
1585
+ responseValidation,
1586
+ });
1587
+ }
1588
+
1589
+ return {
1590
+ ...initialResult,
1591
+ response,
1592
+ };
1593
+ } catch (error) {
1594
+ const mapped = await resolveErrorResult(
1595
+ error,
1596
+ initialResult.ctx,
1597
+ pathValue,
1598
+ queryValue,
1599
+ headersValue,
1600
+ bodyValue,
1601
+ { owner: "framework" },
1602
+ );
1603
+ if (!allowRetry) {
1604
+ return mapped;
1605
+ }
1606
+ const transformed = await applyTransformHooks(mapped, true);
1607
+ return applyResponseFinalizerHooks(
1608
+ transformed,
1609
+ false,
1610
+ "not-applicable",
1611
+ );
1612
+ }
1613
+ };
1614
+
1488
1615
  let result: ExecutionResult<Ctx> | undefined;
1489
1616
 
1617
+ const onRequestStartedAt = performance.now();
1490
1618
  for (const hook of hooks) {
1491
1619
  if (!hook.onRequest) continue;
1492
1620
  try {
@@ -1517,14 +1645,14 @@ function createRequestExecutor<
1517
1645
  break;
1518
1646
  }
1519
1647
  }
1648
+ stages.onRequestMs = performance.now() - onRequestStartedAt;
1520
1649
 
1521
1650
  if (!result) {
1522
1651
  if (optionsOverrides?.skipRoutePreparation) {
1523
1652
  let createdCtx!: Ctx;
1524
1653
  try {
1525
- createdCtx = await contextRuntime.createRequestContext(
1526
- req,
1527
- contract,
1654
+ createdCtx = await timeStage("contextMs", () =>
1655
+ contextRuntime.createRequestContext(req, contract),
1528
1656
  );
1529
1657
  baseCtx = createdCtx;
1530
1658
  } catch (error) {
@@ -1544,15 +1672,17 @@ function createRequestExecutor<
1544
1672
  result = {
1545
1673
  ctx: createdCtx,
1546
1674
  response: normalizeHttpResponse(
1547
- await userHandler({
1548
- req,
1549
- ctx: createdCtx,
1550
- contract,
1551
- path: {} as HandlerArgs<Ctx, C>["path"],
1552
- query: {} as HandlerArgs<Ctx, C>["query"],
1553
- headers: rawHeaders as HandlerArgs<Ctx, C>["headers"],
1554
- body: undefined as HandlerArgs<Ctx, C>["body"],
1555
- }),
1675
+ await timeStage("handlerMs", () =>
1676
+ userHandler({
1677
+ req,
1678
+ ctx: createdCtx,
1679
+ contract,
1680
+ path: {} as HandlerArgs<Ctx, C>["path"],
1681
+ query: {} as HandlerArgs<Ctx, C>["query"],
1682
+ headers: rawHeaders as HandlerArgs<Ctx, C>["headers"],
1683
+ body: undefined as HandlerArgs<Ctx, C>["body"],
1684
+ }),
1685
+ ),
1556
1686
  ),
1557
1687
  owner: "framework",
1558
1688
  };
@@ -1569,6 +1699,7 @@ function createRequestExecutor<
1569
1699
  }
1570
1700
  }
1571
1701
  } else {
1702
+ const parseStartedAt = performance.now();
1572
1703
  const rawQuery: Record<string, string | string[]> = {};
1573
1704
  for (const key of new Set(url.searchParams.keys())) {
1574
1705
  const values = url.searchParams.getAll(key);
@@ -1641,7 +1772,9 @@ function createRequestExecutor<
1641
1772
 
1642
1773
  // biome-ignore lint/suspicious/noExplicitAny: Type will be narrowed by schema validation
1643
1774
  let body: any;
1644
- if (!result) {
1775
+ // Raw routes own body consumption: the handler reads `req` itself,
1776
+ // for example to verify a webhook signature over the exact bytes.
1777
+ if (!result && !optionsOverrides?.rawRoute) {
1645
1778
  try {
1646
1779
  body = await parseBody(req, maxRequestBodyBytes);
1647
1780
  } catch (error) {
@@ -1708,6 +1841,8 @@ function createRequestExecutor<
1708
1841
  }
1709
1842
  }
1710
1843
 
1844
+ stages.parseMs = performance.now() - parseStartedAt;
1845
+
1711
1846
  if (!result) {
1712
1847
  pathValue = path;
1713
1848
  queryValue = query;
@@ -1716,9 +1851,8 @@ function createRequestExecutor<
1716
1851
 
1717
1852
  let createdCtx!: Ctx;
1718
1853
  try {
1719
- createdCtx = await contextRuntime.createRequestContext(
1720
- req,
1721
- contract,
1854
+ createdCtx = await timeStage("contextMs", () =>
1855
+ contextRuntime.createRequestContext(req, contract),
1722
1856
  );
1723
1857
  baseCtx = createdCtx;
1724
1858
  } catch (error) {
@@ -1745,10 +1879,10 @@ function createRequestExecutor<
1745
1879
  };
1746
1880
 
1747
1881
  let currentCtx = createdCtx;
1748
- for (const hook of hooks) {
1749
- if (!hook.beforeHandle) continue;
1882
+ const beforeHandleStartedAt = performance.now();
1883
+ for (const hook of routeHooks) {
1750
1884
  try {
1751
- const hookResult = await hook.beforeHandle({
1885
+ const additions = await hook.resolve({
1752
1886
  req,
1753
1887
  ctx: currentCtx,
1754
1888
  contract,
@@ -1757,33 +1891,11 @@ function createRequestExecutor<
1757
1891
  headers,
1758
1892
  body,
1759
1893
  });
1760
- if (isWebResponse(hookResult)) {
1761
- result = {
1762
- ctx: currentCtx,
1763
- response: hookResult,
1764
- owner: "transport",
1765
- };
1766
- break;
1767
- }
1768
- if (isHttpResponseLike(hookResult)) {
1769
- result = {
1770
- ctx: currentCtx,
1771
- response: normalizeResponse(hookResult),
1772
- owner: "framework",
1773
- };
1774
- break;
1775
- }
1776
- if (hookResult?.ctx !== undefined) {
1777
- currentCtx = contextRuntime.finalizeContext(hookResult.ctx);
1778
- }
1779
- if (hookResult?.response) {
1780
- const response = normalizeHttpResponse(hookResult.response);
1781
- result = {
1782
- ctx: currentCtx,
1783
- response,
1784
- owner: responseOwnerFor(response, "framework"),
1785
- };
1786
- break;
1894
+ if (additions && typeof additions === "object") {
1895
+ currentCtx = contextRuntime.finalizeContext({
1896
+ ...(currentCtx as object),
1897
+ ...additions,
1898
+ } as Ctx);
1787
1899
  }
1788
1900
  } catch (error) {
1789
1901
  result = await resolveErrorResult(
@@ -1800,9 +1912,10 @@ function createRequestExecutor<
1800
1912
  }
1801
1913
 
1802
1914
  if (!result) {
1803
- for (const hook of routeHooks) {
1915
+ for (const hook of hooks) {
1916
+ if (!hook.beforeHandle) continue;
1804
1917
  try {
1805
- const additions = await hook.resolve({
1918
+ const hookResult = await hook.beforeHandle({
1806
1919
  req,
1807
1920
  ctx: currentCtx,
1808
1921
  contract,
@@ -1811,11 +1924,37 @@ function createRequestExecutor<
1811
1924
  headers,
1812
1925
  body,
1813
1926
  });
1814
- if (additions && typeof additions === "object") {
1815
- currentCtx = contextRuntime.finalizeContext({
1816
- ...(currentCtx as object),
1817
- ...additions,
1818
- } as Ctx);
1927
+ if (isWebResponse(hookResult)) {
1928
+ result = {
1929
+ ctx: currentCtx,
1930
+ response: hookResult,
1931
+ owner: "transport",
1932
+ };
1933
+ break;
1934
+ }
1935
+ if (isHttpResponseLike(hookResult)) {
1936
+ result = {
1937
+ ctx: currentCtx,
1938
+ response: normalizeResponse(hookResult),
1939
+ owner: "framework",
1940
+ };
1941
+ break;
1942
+ }
1943
+ if (hookResult?.ctx !== undefined) {
1944
+ currentCtx = contextRuntime.finalizeContext(
1945
+ hookResult.ctx,
1946
+ );
1947
+ }
1948
+ if (hookResult?.response) {
1949
+ const response = normalizeHttpResponse(
1950
+ hookResult.response,
1951
+ );
1952
+ result = {
1953
+ ctx: currentCtx,
1954
+ response,
1955
+ owner: responseOwnerFor(response, "framework"),
1956
+ };
1957
+ break;
1819
1958
  }
1820
1959
  } catch (error) {
1821
1960
  result = await resolveErrorResult(
@@ -1831,6 +1970,7 @@ function createRequestExecutor<
1831
1970
  }
1832
1971
  }
1833
1972
  }
1973
+ stages.beforeHandleMs = performance.now() - beforeHandleStartedAt;
1834
1974
 
1835
1975
  if (!result) {
1836
1976
  // Hooks may have elevated the actor or resolved a tenant.
@@ -1845,7 +1985,9 @@ function createRequestExecutor<
1845
1985
  result = {
1846
1986
  ctx: currentCtx,
1847
1987
  response: normalizeHttpResponse(
1848
- await userHandler({ ...baseArgs, ctx: currentCtx }),
1988
+ await timeStage("handlerMs", () =>
1989
+ userHandler({ ...baseArgs, ctx: currentCtx }),
1990
+ ),
1849
1991
  ),
1850
1992
  };
1851
1993
  } catch (error) {
@@ -1864,12 +2006,21 @@ function createRequestExecutor<
1864
2006
  }
1865
2007
  }
1866
2008
 
2009
+ const sendStartedAt = performance.now();
1867
2010
  result = await applyTransformHooks(result, true);
1868
2011
 
1869
2012
  let finalResponse = normalizeHttpResponse(result.response);
1870
2013
  let finalError = result.error;
1871
2014
  let finalOwner = responseOwnerFor(finalResponse, result.owner);
2015
+ let responseValidation: ResponseFinalizerValidationState =
2016
+ "not-applicable";
1872
2017
  if (
2018
+ finalOwner === "route" &&
2019
+ !isWebResponse(finalResponse) &&
2020
+ !(options.validateResponses ?? true)
2021
+ ) {
2022
+ responseValidation = "disabled";
2023
+ } else if (
1873
2024
  finalOwner === "route" &&
1874
2025
  !isWebResponse(finalResponse) &&
1875
2026
  (options.validateResponses ?? true)
@@ -1880,33 +2031,52 @@ function createRequestExecutor<
1880
2031
  finalResponse,
1881
2032
  target.responseValidationExemptStatus,
1882
2033
  );
2034
+ result = {
2035
+ ...result,
2036
+ response: finalResponse,
2037
+ };
2038
+ responseValidation = "validated";
1883
2039
  } catch (error) {
1884
2040
  if (error instanceof ResponseContractViolationError) {
1885
- result = await applyTransformHooks(
1886
- {
1887
- ctx: result.ctx,
1888
- response: toContractViolationResponse(error),
1889
- error,
1890
- owner: "framework",
1891
- },
1892
- false,
1893
- );
2041
+ result = {
2042
+ ctx: result.ctx,
2043
+ response: toContractViolationResponse(error),
2044
+ error,
2045
+ owner: "framework",
2046
+ };
2047
+ finalResponse = normalizeHttpResponse(result.response);
2048
+ finalError = result.error;
2049
+ finalOwner = responseOwnerFor(finalResponse, result.owner);
2050
+ result = await applyTransformHooks(result, true);
1894
2051
  finalResponse = normalizeHttpResponse(result.response);
1895
2052
  finalError = result.error;
1896
2053
  finalOwner = responseOwnerFor(finalResponse, result.owner);
2054
+ responseValidation = "not-applicable";
1897
2055
  } else {
1898
2056
  throw error;
1899
2057
  }
1900
2058
  }
1901
2059
  }
2060
+
2061
+ result = await applyResponseFinalizerHooks(
2062
+ result,
2063
+ true,
2064
+ responseValidation,
2065
+ );
2066
+ finalResponse = normalizeHttpResponse(result.response);
2067
+ finalError = result.error;
2068
+ finalOwner = responseOwnerFor(finalResponse, result.owner);
2069
+
1902
2070
  if (!isWebResponse(finalResponse)) {
1903
2071
  finalResponse = withFrameworkErrorOwnerHeader(
1904
2072
  finalResponse,
1905
2073
  finalOwner,
1906
2074
  );
1907
2075
  }
2076
+ stages.sendMs = performance.now() - sendStartedAt;
1908
2077
 
1909
2078
  const durationMs = Date.now() - startedAt;
2079
+ const stageTimings = roundStageTimings(stages);
1910
2080
  for (const hook of hooks) {
1911
2081
  if (!hook.afterSend) continue;
1912
2082
  try {
@@ -1921,6 +2091,7 @@ function createRequestExecutor<
1921
2091
  response: responseForHooks(finalResponse),
1922
2092
  error: finalError,
1923
2093
  durationMs,
2094
+ stages: stageTimings,
1924
2095
  });
1925
2096
  } catch {
1926
2097
  // Ignore after-response hook failures; they should never change the response.
@@ -1976,6 +2147,12 @@ function buildHandler<
1976
2147
  routeHooks: readonly RouteHook<unknown, object>[] = [],
1977
2148
  optionsOverrides?: {
1978
2149
  skipRoutePreparation?: boolean;
2150
+ /**
2151
+ * Run the route through the full hook pipeline without contract
2152
+ * preparation: no query/path/header/body parsing or validation, and the
2153
+ * request body is left unconsumed for the handler.
2154
+ */
2155
+ rawRoute?: boolean;
1979
2156
  },
1980
2157
  responseValidationExemptStatus?: number,
1981
2158
  ): (
@@ -2491,6 +2668,24 @@ export async function createServer<
2491
2668
  const contract = resolveContract(contractLike);
2492
2669
  return createBuilder(contract, true);
2493
2670
  },
2671
+ rawRoute: (init) => ({
2672
+ handle: (fn) => {
2673
+ const built = buildHandler(
2674
+ options,
2675
+ finalPorts,
2676
+ contextRuntime,
2677
+ rawRouteContract(init),
2678
+ fn,
2679
+ hooks,
2680
+ [],
2681
+ { rawRoute: true },
2682
+ );
2683
+ // The adapter owns routing for raw routes — the handler is mounted
2684
+ // at its own path — so path/method matching is skipped and
2685
+ // `init.path` stays identity for hooks and instrumentation.
2686
+ return (req) => built(req, {});
2687
+ },
2688
+ }),
2494
2689
  createRequestContext: (req) => createRequestContext(req),
2495
2690
  createServiceContext,
2496
2691
  runServiceContext,
@@ -2500,6 +2695,32 @@ export async function createServer<
2500
2695
  };
2501
2696
  }
2502
2697
 
2698
+ function roundStageTimings(stages: RequestStageTimings): RequestStageTimings {
2699
+ const round = (value: number) => Math.round(value * 100) / 100;
2700
+ return {
2701
+ onRequestMs: round(stages.onRequestMs),
2702
+ parseMs: round(stages.parseMs),
2703
+ contextMs: round(stages.contextMs),
2704
+ beforeHandleMs: round(stages.beforeHandleMs),
2705
+ handlerMs: round(stages.handlerMs),
2706
+ sendMs: round(stages.sendMs),
2707
+ };
2708
+ }
2709
+
2710
+ function rawRouteContract(init: RawRouteInit): HttpContractConfig {
2711
+ return {
2712
+ kind: "http",
2713
+ name: init.name,
2714
+ method: init.method,
2715
+ path: init.path,
2716
+ pathParams: null,
2717
+ query: null,
2718
+ body: null,
2719
+ responses: {},
2720
+ metadata: init.metadata ?? {},
2721
+ };
2722
+ }
2723
+
2503
2724
  /**
2504
2725
  * Define and flatten route registrations with strong type inference.
2505
2726
  *