@beignet/core 0.0.26 → 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.
Files changed (53) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +76 -11
  3. package/dist/idempotency/index.d.ts +2 -2
  4. package/dist/idempotency/index.d.ts.map +1 -1
  5. package/dist/idempotency/index.js +17 -9
  6. package/dist/idempotency/index.js.map +1 -1
  7. package/dist/ports/rate-limit.d.ts.map +1 -1
  8. package/dist/ports/rate-limit.js +12 -0
  9. package/dist/ports/rate-limit.js.map +1 -1
  10. package/dist/server/fixtures/run-service-context-script.fixture.d.ts +12 -0
  11. package/dist/server/fixtures/run-service-context-script.fixture.d.ts.map +1 -0
  12. package/dist/server/fixtures/run-service-context-script.fixture.js +36 -0
  13. package/dist/server/fixtures/run-service-context-script.fixture.js.map +1 -0
  14. package/dist/server/hooks/idempotency.d.ts +7 -6
  15. package/dist/server/hooks/idempotency.d.ts.map +1 -1
  16. package/dist/server/hooks/idempotency.js +16 -11
  17. package/dist/server/hooks/idempotency.js.map +1 -1
  18. package/dist/server/hooks/rate-limit.d.ts +22 -7
  19. package/dist/server/hooks/rate-limit.d.ts.map +1 -1
  20. package/dist/server/hooks/rate-limit.js +45 -8
  21. package/dist/server/hooks/rate-limit.js.map +1 -1
  22. package/dist/server/http.d.ts +19 -5
  23. package/dist/server/http.d.ts.map +1 -1
  24. package/dist/server/internal-hooks.d.ts +25 -0
  25. package/dist/server/internal-hooks.d.ts.map +1 -0
  26. package/dist/server/internal-hooks.js +5 -0
  27. package/dist/server/internal-hooks.js.map +1 -0
  28. package/dist/server/request-context.d.ts +9 -0
  29. package/dist/server/request-context.d.ts.map +1 -1
  30. package/dist/server/request-context.js +11 -0
  31. package/dist/server/request-context.js.map +1 -1
  32. package/dist/server/server.d.ts +19 -0
  33. package/dist/server/server.d.ts.map +1 -1
  34. package/dist/server/server.js +135 -41
  35. package/dist/server/server.js.map +1 -1
  36. package/dist/tasks/index.d.ts +22 -0
  37. package/dist/tasks/index.d.ts.map +1 -1
  38. package/dist/tasks/index.js.map +1 -1
  39. package/package.json +1 -1
  40. package/skills/app-architecture/SKILL.md +1 -1
  41. package/src/idempotency/index.ts +21 -13
  42. package/src/ports/rate-limit.ts +12 -0
  43. package/src/providers/instrumentation.ts +13 -0
  44. package/src/server/fixtures/run-service-context-script.fixture.ts +45 -0
  45. package/src/server/hooks/idempotency.ts +28 -10
  46. package/src/server/hooks/rate-limit.ts +64 -11
  47. package/src/server/http.ts +41 -5
  48. package/src/server/index.ts +2 -0
  49. package/src/server/instrumentation.ts +10 -1
  50. package/src/server/internal-hooks.ts +49 -0
  51. package/src/server/request-context.ts +15 -0
  52. package/src/server/server.ts +353 -64
  53. package/src/tasks/index.ts +23 -0
@@ -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,
@@ -71,6 +77,7 @@ import {
71
77
  enterActiveRequestContext,
72
78
  readContextActor,
73
79
  readContextTenant,
80
+ runWithActiveRequestContext,
74
81
  setActiveRequestIdentity,
75
82
  } from "./request-context.js";
76
83
  import type {
@@ -398,6 +405,30 @@ interface RouteBuilder<Ctx, C extends HttpContractConfig> {
398
405
  ) => (req: HttpRequestLike) => Promise<HttpResponse>;
399
406
  }
400
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
+
401
432
  /**
402
433
  * Runtime server object returned by `createServer(...)`.
403
434
  */
@@ -416,6 +447,21 @@ export interface ServerInstance<
416
447
  route: <CLike extends ContractLike>(
417
448
  contractLike: CLike,
418
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>;
419
465
  /**
420
466
  * Build a fully assembled request context from a framework-neutral request.
421
467
  *
@@ -428,10 +474,31 @@ export interface ServerInstance<
428
474
  * tasks, and background work.
429
475
  *
430
476
  * Requires `context.service` to be declared in `createServer(...)`.
477
+ *
478
+ * Only call this from long-lived runtimes such as servers, workers, and
479
+ * `bun test`. It enters the ambient correlation context with
480
+ * `AsyncLocalStorage.enterWith`, and resuming that frame across top-level
481
+ * await crashes Bun 1.3.x in plain scripts. Seeds and one-off scripts must
482
+ * use `runServiceContext(...)` instead.
431
483
  */
432
484
  createServiceContext: (
433
485
  ...args: ServiceContextInputArgs<ServiceInput>
434
486
  ) => Promise<Ctx>;
487
+ /**
488
+ * Build a service context and run `fn` inside a scoped ambient correlation
489
+ * frame, returning its result.
490
+ *
491
+ * This is the script-safe counterpart to `createServiceContext(...)`: the
492
+ * ambient frame is entered with `AsyncLocalStorage.run`, so plain scripts
493
+ * such as seeds and one-off maintenance work stay safe under top-level
494
+ * await. Requires `context.service` to be declared in `createServer(...)`.
495
+ */
496
+ runServiceContext: <T>(
497
+ ...args: [
498
+ ...ServiceContextInputArgs<ServiceInput>,
499
+ fn: (ctx: Ctx) => T | Promise<T>,
500
+ ]
501
+ ) => Promise<T>;
435
502
  /**
436
503
  * Contract configs registered through the `routes` option.
437
504
  */
@@ -453,7 +520,7 @@ type ExecutionResult<Ctx> = {
453
520
  owner?: ResponseOwner;
454
521
  };
455
522
 
456
- type ResponseOwner = "route" | "framework" | "transport";
523
+ type ResponseOwner = ResponseFinalizerResponseOwner;
457
524
 
458
525
  type CompiledPath = {
459
526
  keys: string[];
@@ -1103,6 +1170,12 @@ function createRequestExecutor<
1103
1170
  routeHooks: readonly RouteHook<unknown, object>[] = [],
1104
1171
  optionsOverrides?: {
1105
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;
1106
1179
  },
1107
1180
  ): (
1108
1181
  target: ExecutionTarget<Ctx, C>,
@@ -1124,6 +1197,27 @@ function createRequestExecutor<
1124
1197
  let headersValue: HandlerArgs<Ctx, C>["headers"] | undefined;
1125
1198
  let bodyValue: HandlerArgs<Ctx, C>["body"] | undefined;
1126
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
+ };
1127
1221
 
1128
1222
  const resolveErrorResult = async (
1129
1223
  error: unknown,
@@ -1463,8 +1557,64 @@ function createRequestExecutor<
1463
1557
  }
1464
1558
  };
1465
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
+
1466
1615
  let result: ExecutionResult<Ctx> | undefined;
1467
1616
 
1617
+ const onRequestStartedAt = performance.now();
1468
1618
  for (const hook of hooks) {
1469
1619
  if (!hook.onRequest) continue;
1470
1620
  try {
@@ -1495,14 +1645,14 @@ function createRequestExecutor<
1495
1645
  break;
1496
1646
  }
1497
1647
  }
1648
+ stages.onRequestMs = performance.now() - onRequestStartedAt;
1498
1649
 
1499
1650
  if (!result) {
1500
1651
  if (optionsOverrides?.skipRoutePreparation) {
1501
1652
  let createdCtx!: Ctx;
1502
1653
  try {
1503
- createdCtx = await contextRuntime.createRequestContext(
1504
- req,
1505
- contract,
1654
+ createdCtx = await timeStage("contextMs", () =>
1655
+ contextRuntime.createRequestContext(req, contract),
1506
1656
  );
1507
1657
  baseCtx = createdCtx;
1508
1658
  } catch (error) {
@@ -1522,15 +1672,17 @@ function createRequestExecutor<
1522
1672
  result = {
1523
1673
  ctx: createdCtx,
1524
1674
  response: normalizeHttpResponse(
1525
- await userHandler({
1526
- req,
1527
- ctx: createdCtx,
1528
- contract,
1529
- path: {} as HandlerArgs<Ctx, C>["path"],
1530
- query: {} as HandlerArgs<Ctx, C>["query"],
1531
- headers: rawHeaders as HandlerArgs<Ctx, C>["headers"],
1532
- body: undefined as HandlerArgs<Ctx, C>["body"],
1533
- }),
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
+ ),
1534
1686
  ),
1535
1687
  owner: "framework",
1536
1688
  };
@@ -1547,6 +1699,7 @@ function createRequestExecutor<
1547
1699
  }
1548
1700
  }
1549
1701
  } else {
1702
+ const parseStartedAt = performance.now();
1550
1703
  const rawQuery: Record<string, string | string[]> = {};
1551
1704
  for (const key of new Set(url.searchParams.keys())) {
1552
1705
  const values = url.searchParams.getAll(key);
@@ -1619,7 +1772,9 @@ function createRequestExecutor<
1619
1772
 
1620
1773
  // biome-ignore lint/suspicious/noExplicitAny: Type will be narrowed by schema validation
1621
1774
  let body: any;
1622
- 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) {
1623
1778
  try {
1624
1779
  body = await parseBody(req, maxRequestBodyBytes);
1625
1780
  } catch (error) {
@@ -1686,6 +1841,8 @@ function createRequestExecutor<
1686
1841
  }
1687
1842
  }
1688
1843
 
1844
+ stages.parseMs = performance.now() - parseStartedAt;
1845
+
1689
1846
  if (!result) {
1690
1847
  pathValue = path;
1691
1848
  queryValue = query;
@@ -1694,9 +1851,8 @@ function createRequestExecutor<
1694
1851
 
1695
1852
  let createdCtx!: Ctx;
1696
1853
  try {
1697
- createdCtx = await contextRuntime.createRequestContext(
1698
- req,
1699
- contract,
1854
+ createdCtx = await timeStage("contextMs", () =>
1855
+ contextRuntime.createRequestContext(req, contract),
1700
1856
  );
1701
1857
  baseCtx = createdCtx;
1702
1858
  } catch (error) {
@@ -1723,10 +1879,10 @@ function createRequestExecutor<
1723
1879
  };
1724
1880
 
1725
1881
  let currentCtx = createdCtx;
1726
- for (const hook of hooks) {
1727
- if (!hook.beforeHandle) continue;
1882
+ const beforeHandleStartedAt = performance.now();
1883
+ for (const hook of routeHooks) {
1728
1884
  try {
1729
- const hookResult = await hook.beforeHandle({
1885
+ const additions = await hook.resolve({
1730
1886
  req,
1731
1887
  ctx: currentCtx,
1732
1888
  contract,
@@ -1735,33 +1891,11 @@ function createRequestExecutor<
1735
1891
  headers,
1736
1892
  body,
1737
1893
  });
1738
- if (isWebResponse(hookResult)) {
1739
- result = {
1740
- ctx: currentCtx,
1741
- response: hookResult,
1742
- owner: "transport",
1743
- };
1744
- break;
1745
- }
1746
- if (isHttpResponseLike(hookResult)) {
1747
- result = {
1748
- ctx: currentCtx,
1749
- response: normalizeResponse(hookResult),
1750
- owner: "framework",
1751
- };
1752
- break;
1753
- }
1754
- if (hookResult?.ctx !== undefined) {
1755
- currentCtx = contextRuntime.finalizeContext(hookResult.ctx);
1756
- }
1757
- if (hookResult?.response) {
1758
- const response = normalizeHttpResponse(hookResult.response);
1759
- result = {
1760
- ctx: currentCtx,
1761
- response,
1762
- owner: responseOwnerFor(response, "framework"),
1763
- };
1764
- break;
1894
+ if (additions && typeof additions === "object") {
1895
+ currentCtx = contextRuntime.finalizeContext({
1896
+ ...(currentCtx as object),
1897
+ ...additions,
1898
+ } as Ctx);
1765
1899
  }
1766
1900
  } catch (error) {
1767
1901
  result = await resolveErrorResult(
@@ -1778,9 +1912,10 @@ function createRequestExecutor<
1778
1912
  }
1779
1913
 
1780
1914
  if (!result) {
1781
- for (const hook of routeHooks) {
1915
+ for (const hook of hooks) {
1916
+ if (!hook.beforeHandle) continue;
1782
1917
  try {
1783
- const additions = await hook.resolve({
1918
+ const hookResult = await hook.beforeHandle({
1784
1919
  req,
1785
1920
  ctx: currentCtx,
1786
1921
  contract,
@@ -1789,11 +1924,37 @@ function createRequestExecutor<
1789
1924
  headers,
1790
1925
  body,
1791
1926
  });
1792
- if (additions && typeof additions === "object") {
1793
- currentCtx = contextRuntime.finalizeContext({
1794
- ...(currentCtx as object),
1795
- ...additions,
1796
- } 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;
1797
1958
  }
1798
1959
  } catch (error) {
1799
1960
  result = await resolveErrorResult(
@@ -1809,6 +1970,7 @@ function createRequestExecutor<
1809
1970
  }
1810
1971
  }
1811
1972
  }
1973
+ stages.beforeHandleMs = performance.now() - beforeHandleStartedAt;
1812
1974
 
1813
1975
  if (!result) {
1814
1976
  // Hooks may have elevated the actor or resolved a tenant.
@@ -1823,7 +1985,9 @@ function createRequestExecutor<
1823
1985
  result = {
1824
1986
  ctx: currentCtx,
1825
1987
  response: normalizeHttpResponse(
1826
- await userHandler({ ...baseArgs, ctx: currentCtx }),
1988
+ await timeStage("handlerMs", () =>
1989
+ userHandler({ ...baseArgs, ctx: currentCtx }),
1990
+ ),
1827
1991
  ),
1828
1992
  };
1829
1993
  } catch (error) {
@@ -1842,12 +2006,21 @@ function createRequestExecutor<
1842
2006
  }
1843
2007
  }
1844
2008
 
2009
+ const sendStartedAt = performance.now();
1845
2010
  result = await applyTransformHooks(result, true);
1846
2011
 
1847
2012
  let finalResponse = normalizeHttpResponse(result.response);
1848
2013
  let finalError = result.error;
1849
2014
  let finalOwner = responseOwnerFor(finalResponse, result.owner);
2015
+ let responseValidation: ResponseFinalizerValidationState =
2016
+ "not-applicable";
1850
2017
  if (
2018
+ finalOwner === "route" &&
2019
+ !isWebResponse(finalResponse) &&
2020
+ !(options.validateResponses ?? true)
2021
+ ) {
2022
+ responseValidation = "disabled";
2023
+ } else if (
1851
2024
  finalOwner === "route" &&
1852
2025
  !isWebResponse(finalResponse) &&
1853
2026
  (options.validateResponses ?? true)
@@ -1858,33 +2031,52 @@ function createRequestExecutor<
1858
2031
  finalResponse,
1859
2032
  target.responseValidationExemptStatus,
1860
2033
  );
2034
+ result = {
2035
+ ...result,
2036
+ response: finalResponse,
2037
+ };
2038
+ responseValidation = "validated";
1861
2039
  } catch (error) {
1862
2040
  if (error instanceof ResponseContractViolationError) {
1863
- result = await applyTransformHooks(
1864
- {
1865
- ctx: result.ctx,
1866
- response: toContractViolationResponse(error),
1867
- error,
1868
- owner: "framework",
1869
- },
1870
- false,
1871
- );
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);
1872
2051
  finalResponse = normalizeHttpResponse(result.response);
1873
2052
  finalError = result.error;
1874
2053
  finalOwner = responseOwnerFor(finalResponse, result.owner);
2054
+ responseValidation = "not-applicable";
1875
2055
  } else {
1876
2056
  throw error;
1877
2057
  }
1878
2058
  }
1879
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
+
1880
2070
  if (!isWebResponse(finalResponse)) {
1881
2071
  finalResponse = withFrameworkErrorOwnerHeader(
1882
2072
  finalResponse,
1883
2073
  finalOwner,
1884
2074
  );
1885
2075
  }
2076
+ stages.sendMs = performance.now() - sendStartedAt;
1886
2077
 
1887
2078
  const durationMs = Date.now() - startedAt;
2079
+ const stageTimings = roundStageTimings(stages);
1888
2080
  for (const hook of hooks) {
1889
2081
  if (!hook.afterSend) continue;
1890
2082
  try {
@@ -1899,6 +2091,7 @@ function createRequestExecutor<
1899
2091
  response: responseForHooks(finalResponse),
1900
2092
  error: finalError,
1901
2093
  durationMs,
2094
+ stages: stageTimings,
1902
2095
  });
1903
2096
  } catch {
1904
2097
  // Ignore after-response hook failures; they should never change the response.
@@ -1954,6 +2147,12 @@ function buildHandler<
1954
2147
  routeHooks: readonly RouteHook<unknown, object>[] = [],
1955
2148
  optionsOverrides?: {
1956
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;
1957
2156
  },
1958
2157
  responseValidationExemptStatus?: number,
1959
2158
  ): (
@@ -2032,6 +2231,11 @@ export async function createServer<
2032
2231
  ];
2033
2232
  const contracts = options.routes ? contractsFromRoutes(options.routes) : [];
2034
2233
 
2234
+ // Fail startup on hook misconfiguration before provider setup runs.
2235
+ for (const hook of hooks) {
2236
+ hook.validate?.({ contracts });
2237
+ }
2238
+
2035
2239
  const resolvedContext = resolveServerContext<Ctx, FinalPorts, ServiceInput>(
2036
2240
  options.context,
2037
2241
  );
@@ -2089,6 +2293,46 @@ export async function createServer<
2089
2293
  ambient.tenant = readContextTenant(ctx);
2090
2294
  return ctx;
2091
2295
  };
2296
+ const runServiceContext = async <T>(
2297
+ ...args: [
2298
+ ...ServiceContextInputArgs<ServiceInput>,
2299
+ fn: (ctx: Ctx) => T | Promise<T>,
2300
+ ]
2301
+ ): Promise<T> => {
2302
+ const serviceFactory = resolvedContext.service;
2303
+ if (!serviceFactory) {
2304
+ throw new Error(
2305
+ "Define context.service in createServer(...) to create service contexts.",
2306
+ );
2307
+ }
2308
+
2309
+ const fn = args[args.length - 1] as (ctx: Ctx) => T | Promise<T>;
2310
+ const input = (args.length > 1 ? args[0] : undefined) as ServiceInput;
2311
+ const { requestId, trace } = instrumentation.createServiceCorrelation();
2312
+ const ambient: ActiveRequestContext = {
2313
+ requestId,
2314
+ traceId: trace.traceId,
2315
+ spanId: trace.spanId,
2316
+ parentSpanId: trace.parentSpanId,
2317
+ traceparent: trace.traceparent,
2318
+ };
2319
+ // AsyncLocalStorage.run scopes the ambient frame to this callback, so the
2320
+ // caller's continuation never resumes through an enterWith frame — the
2321
+ // pattern that crashes Bun 1.3.x in plain scripts under top-level await.
2322
+ return runWithActiveRequestContext(ambient, async () => {
2323
+ const ctx = finalizeContext(
2324
+ await serviceFactory({
2325
+ ports: finalPorts,
2326
+ input,
2327
+ requestId,
2328
+ trace,
2329
+ }),
2330
+ );
2331
+ ambient.actor = readContextActor(ctx);
2332
+ ambient.tenant = readContextTenant(ctx);
2333
+ return await fn(ctx);
2334
+ });
2335
+ };
2092
2336
  const contextRuntime = {
2093
2337
  createRequestContext,
2094
2338
  finalizeContext,
@@ -2424,14 +2668,59 @@ export async function createServer<
2424
2668
  const contract = resolveContract(contractLike);
2425
2669
  return createBuilder(contract, true);
2426
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
+ }),
2427
2689
  createRequestContext: (req) => createRequestContext(req),
2428
2690
  createServiceContext,
2691
+ runServiceContext,
2429
2692
  contracts,
2430
2693
  stop,
2431
2694
  ports: finalPorts,
2432
2695
  };
2433
2696
  }
2434
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
+
2435
2724
  /**
2436
2725
  * Define and flatten route registrations with strong type inference.
2437
2726
  *
@@ -121,6 +121,29 @@ export interface RunTaskOptions<Ctx> {
121
121
  ctx: Ctx;
122
122
  }
123
123
 
124
+ /**
125
+ * Arguments `beignet task run` passes to the app's `createTaskContext` and
126
+ * `stopTaskContext` exports in `server/tasks.ts`.
127
+ */
128
+ export interface TaskRunContextArgs<T extends TaskDef = TaskDef> {
129
+ /**
130
+ * Task definition being run.
131
+ */
132
+ task: T;
133
+ /**
134
+ * Stable task name being run.
135
+ */
136
+ taskName: string;
137
+ /**
138
+ * Schema-parsed task input.
139
+ */
140
+ input: unknown;
141
+ /**
142
+ * Tenant id or slug from `--tenant`, resolved by the app.
143
+ */
144
+ tenant?: string;
145
+ }
146
+
124
147
  /**
125
148
  * Context-bound operational task helper factory.
126
149
  */