@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.
- package/CHANGELOG.md +17 -0
- package/README.md +76 -11
- package/dist/idempotency/index.d.ts +2 -2
- package/dist/idempotency/index.d.ts.map +1 -1
- package/dist/idempotency/index.js +17 -9
- package/dist/idempotency/index.js.map +1 -1
- package/dist/ports/rate-limit.d.ts.map +1 -1
- package/dist/ports/rate-limit.js +12 -0
- package/dist/ports/rate-limit.js.map +1 -1
- package/dist/server/fixtures/run-service-context-script.fixture.d.ts +12 -0
- package/dist/server/fixtures/run-service-context-script.fixture.d.ts.map +1 -0
- package/dist/server/fixtures/run-service-context-script.fixture.js +36 -0
- package/dist/server/fixtures/run-service-context-script.fixture.js.map +1 -0
- package/dist/server/hooks/idempotency.d.ts +7 -6
- package/dist/server/hooks/idempotency.d.ts.map +1 -1
- package/dist/server/hooks/idempotency.js +16 -11
- package/dist/server/hooks/idempotency.js.map +1 -1
- package/dist/server/hooks/rate-limit.d.ts +22 -7
- package/dist/server/hooks/rate-limit.d.ts.map +1 -1
- package/dist/server/hooks/rate-limit.js +45 -8
- package/dist/server/hooks/rate-limit.js.map +1 -1
- package/dist/server/http.d.ts +19 -5
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/internal-hooks.d.ts +25 -0
- package/dist/server/internal-hooks.d.ts.map +1 -0
- package/dist/server/internal-hooks.js +5 -0
- package/dist/server/internal-hooks.js.map +1 -0
- package/dist/server/request-context.d.ts +9 -0
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +11 -0
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/server.d.ts +19 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +135 -41
- package/dist/server/server.js.map +1 -1
- package/dist/tasks/index.d.ts +22 -0
- package/dist/tasks/index.d.ts.map +1 -1
- package/dist/tasks/index.js.map +1 -1
- package/package.json +1 -1
- package/skills/app-architecture/SKILL.md +1 -1
- package/src/idempotency/index.ts +21 -13
- package/src/ports/rate-limit.ts +12 -0
- package/src/providers/instrumentation.ts +13 -0
- package/src/server/fixtures/run-service-context-script.fixture.ts +45 -0
- package/src/server/hooks/idempotency.ts +28 -10
- package/src/server/hooks/rate-limit.ts +64 -11
- package/src/server/http.ts +41 -5
- package/src/server/index.ts +2 -0
- package/src/server/instrumentation.ts +10 -1
- package/src/server/internal-hooks.ts +49 -0
- package/src/server/request-context.ts +15 -0
- package/src/server/server.ts +353 -64
- package/src/tasks/index.ts +23 -0
package/src/server/server.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1727
|
-
|
|
1882
|
+
const beforeHandleStartedAt = performance.now();
|
|
1883
|
+
for (const hook of routeHooks) {
|
|
1728
1884
|
try {
|
|
1729
|
-
const
|
|
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 (
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
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
|
|
1915
|
+
for (const hook of hooks) {
|
|
1916
|
+
if (!hook.beforeHandle) continue;
|
|
1782
1917
|
try {
|
|
1783
|
-
const
|
|
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 (
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
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
|
|
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 =
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
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
|
*
|
package/src/tasks/index.ts
CHANGED
|
@@ -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
|
*/
|