@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
@@ -31,7 +31,9 @@ type EarlyRateLimitScope = Exclude<RateLimitScope, "user">;
31
31
  /**
32
32
  * Strategy for resolving the client IP used by `ip`-scoped limits.
33
33
  *
34
- * - `"none"` (default): do not trust request headers for IP resolution.
34
+ * - `"none"`: do not trust request headers for IP resolution. Every request
35
+ * shares one `ip:unknown` bucket; this is the explicit opt-out for apps
36
+ * that declare `ip` scopes without a trusted client-IP source.
35
37
  * - `"x-forwarded-for-last"`: the last `x-forwarded-for` entry. Use this only
36
38
  * when the app is always behind a trusted reverse proxy that appends the
37
39
  * socket address.
@@ -73,8 +75,11 @@ export interface RateLimitOptions<Ctx> {
73
75
  /**
74
76
  * Resolve the client IP for `ip`-scoped limits.
75
77
  *
76
- * Defaults to `"none"`. Pass an explicit trusted proxy strategy or a
77
- * function for platform-specific headers.
78
+ * There is no default: when any contract declares an `ip`-scoped rate limit
79
+ * and neither `ipSource` nor a custom `earlyKey` is configured, the hook
80
+ * fails at startup. Pass an explicit trusted proxy strategy, a function for
81
+ * platform-specific headers, or `"none"` to explicitly accept one shared
82
+ * `ip:unknown` bucket for all clients.
78
83
  */
79
84
  ipSource?: RateLimitIpSource;
80
85
  }
@@ -109,6 +114,20 @@ function resolveClientIp(
109
114
  : entries[entries.length - 1];
110
115
  }
111
116
 
117
+ function formatContractNames(names: readonly string[]): string {
118
+ return names.map((name) => `"${name}"`).join(", ");
119
+ }
120
+
121
+ function ipSourceConfigurationError(contractNames: string): Error {
122
+ return new Error(
123
+ `createRateLimitHooks(...) has no ipSource configured, but contract(s) ${contractNames} declare an "ip"-scoped rate limit. ` +
124
+ `Set ipSource to "x-forwarded-for-last" (app is always behind a trusted reverse proxy that appends the socket address), ` +
125
+ `"x-forwarded-for-first" (a trusted edge normalizes the header before it reaches the app), ` +
126
+ `a function for platform-specific headers such as cf-connecting-ip, ` +
127
+ `or "none" to explicitly accept one shared ip:unknown bucket for all clients.`,
128
+ );
129
+ }
130
+
112
131
  function emitUserKey(userId: string): string {
113
132
  return `user:${userId}`;
114
133
  }
@@ -210,10 +229,20 @@ async function enforceRateLimit(
210
229
  *
211
230
  * The hook reads `contract.metadata.rateLimit`. Global and IP-scoped limits run
212
231
  * in `onRequest` before context creation; user-scoped limits run in
213
- * `beforeHandle` after `ctx.actor` is available. Exceeded limits throw the
214
- * framework `TooManyRequests` app error with `scope`, `retryAfterSeconds`, and
215
- * `resetAt` details. The bucket key is never sent to clients; denials emit a
216
- * `rateLimit.denied` instrumentation event that carries the key for operators.
232
+ * `beforeHandle` after route hooks have resolved identity and `ctx.actor` is
233
+ * available. Exceeded limits throw the framework `TooManyRequests` app error
234
+ * with `scope`, `retryAfterSeconds`, and `resetAt` details. The bucket key is
235
+ * never sent to clients; denials emit a `rateLimit.denied` instrumentation
236
+ * event that carries the key for operators.
237
+ *
238
+ * `ip`-scoped limits require an explicit `ipSource` (or a custom `earlyKey`):
239
+ * the hook's `validate` phase fails `createServer(...)` startup when a
240
+ * registered contract declares an `ip` scope without one, instead of silently
241
+ * collapsing all clients into a shared `ip:unknown` bucket. Contracts added
242
+ * later through `server.route(...)` are not visible to `validate`, so
243
+ * enforcing an `ip`-scoped limit without an `ipSource` throws the same
244
+ * configuration error at request time as a backstop. Pass `ipSource: "none"`
245
+ * to explicitly opt in to the shared `ip:unknown` bucket.
217
246
  *
218
247
  * @param options - Optional key builders and client-IP source.
219
248
  * @returns A server hook backed by `ctx.ports.rateLimit`.
@@ -221,11 +250,31 @@ async function enforceRateLimit(
221
250
  export function createRateLimitHooks<Ctx extends CtxWithRateLimit>(
222
251
  options: RateLimitOptions<Ctx> = {},
223
252
  ): ServerHook<Ctx, RateLimitPorts> {
224
- const ipSource = options.ipSource ?? "none";
225
- const getClientIp = (req: HttpRequestLike) => resolveClientIp(req, ipSource);
253
+ const { ipSource } = options;
254
+ const getClientIp = (
255
+ req: HttpRequestLike,
256
+ contractName: string,
257
+ ): string | undefined => {
258
+ if (ipSource === undefined) {
259
+ throw ipSourceConfigurationError(formatContractNames([contractName]));
260
+ }
261
+ return resolveClientIp(req, ipSource);
262
+ };
226
263
 
227
264
  return {
228
265
  name: "rate-limit",
266
+ validate: ({ contracts }) => {
267
+ if (ipSource !== undefined || options.earlyKey) {
268
+ return;
269
+ }
270
+ const ipScoped = contracts
271
+ .filter((contract) => contract.metadata?.rateLimit?.scope === "ip")
272
+ .map((contract) => contract.name);
273
+ if (ipScoped.length === 0) {
274
+ return;
275
+ }
276
+ throw ipSourceConfigurationError(formatContractNames(ipScoped));
277
+ },
229
278
  onRequest: async ({ contract, ports, req }) => {
230
279
  const rlMeta = contract.metadata?.rateLimit;
231
280
  if (!rlMeta) {
@@ -239,7 +288,9 @@ export function createRateLimitHooks<Ctx extends CtxWithRateLimit>(
239
288
 
240
289
  const key =
241
290
  options.earlyKey?.({ req, scope }) ??
242
- defaultEarlyRateLimitKey({ req, scope }, getClientIp);
291
+ defaultEarlyRateLimitKey({ req, scope }, (r) =>
292
+ getClientIp(r, contract.name),
293
+ );
243
294
 
244
295
  await enforceRateLimit(ports, {
245
296
  key,
@@ -263,7 +314,9 @@ export function createRateLimitHooks<Ctx extends CtxWithRateLimit>(
263
314
 
264
315
  const key =
265
316
  options.key?.({ ctx, req, scope }) ??
266
- defaultRateLimitKey({ ctx, req, scope }, getClientIp);
317
+ defaultRateLimitKey({ ctx, req, scope }, (r) =>
318
+ getClientIp(r, contract.name),
319
+ );
267
320
 
268
321
  await enforceRateLimit(ctx.ports, {
269
322
  key,
@@ -340,6 +340,26 @@ export type BeforeSendHook<
340
340
  native?: boolean;
341
341
  }) => MaybePromise<HttpResponseLike | undefined>;
342
342
 
343
+ /**
344
+ * Per-stage timing breakdown of one request, in milliseconds.
345
+ *
346
+ * Stages are measured around the pipeline phases in execution order:
347
+ * `onRequest` hooks, request parsing/validation, context creation, route
348
+ * hooks plus `beforeHandle` hooks, the route handler, and response
349
+ * preparation (`beforeSend`, response validation, and finalizers). Stages
350
+ * that did not run for a request — parsing on raw routes, the handler after
351
+ * a hook short-circuit — report `0`. The stages do not sum exactly to the
352
+ * request `durationMs`; routing and bookkeeping live in the gaps.
353
+ */
354
+ export type RequestStageTimings = {
355
+ onRequestMs: number;
356
+ parseMs: number;
357
+ contextMs: number;
358
+ beforeHandleMs: number;
359
+ handlerMs: number;
360
+ sendMs: number;
361
+ };
362
+
343
363
  /**
344
364
  * Hook that runs after the response has been prepared.
345
365
  *
@@ -360,6 +380,10 @@ export type AfterSendHook<
360
380
  response: HttpResponseLike;
361
381
  error?: unknown;
362
382
  durationMs: number;
383
+ /**
384
+ * Per-stage timing breakdown of this request.
385
+ */
386
+ stages: RequestStageTimings;
363
387
  }) => MaybePromise<void>;
364
388
 
365
389
  /**
@@ -402,22 +426,34 @@ export type ServerUnhandledErrorMapper<
402
426
  /**
403
427
  * Server lifecycle hook collection.
404
428
  *
405
- * Hooks run in the order they are registered. `onRequest` can short-circuit
406
- * before context creation; `beforeHandle` can replace context or short-circuit;
407
- * `beforeSend` can replace the outgoing response; `afterSend` observes the
408
- * final response.
429
+ * Hooks run in the order they are registered within each server-hook phase.
430
+ * `onRequest` can short-circuit before context creation; route hooks resolve
431
+ * route-scoped context before server `beforeHandle`; `beforeHandle` can replace
432
+ * context or short-circuit; `beforeSend` can replace the outgoing response, and
433
+ * route-owned replacements are contract-validated before send; `afterSend`
434
+ * observes the final response.
409
435
  */
410
436
  export interface ServerHook<Ctx, Ports extends AnyPorts = AnyPorts> {
411
437
  /**
412
438
  * Optional name used in diagnostics and devtools.
413
439
  */
414
440
  name?: string;
441
+ /**
442
+ * Validates the hook configuration against the registered contracts.
443
+ *
444
+ * Invoked once at startup, right after `createServer(...)` collects the
445
+ * contracts from the `routes` option and before provider setup. Throw to
446
+ * fail startup instead of degrading silently at request time. Contracts
447
+ * registered later through `server.route(...)` are not seen here, so hooks
448
+ * that need full coverage should keep a runtime check as a backstop.
449
+ */
450
+ validate?: (args: { contracts: readonly HttpContractConfig[] }) => void;
415
451
  /**
416
452
  * Runs after route matching and before body/query/header parsing.
417
453
  */
418
454
  onRequest?: OnRequestHook<Ports>;
419
455
  /**
420
- * Runs after request parsing and context creation.
456
+ * Runs after request parsing, context creation, and route hook resolution.
421
457
  */
422
458
  beforeHandle?: BeforeHandleHook<Ctx>;
423
459
  /**
@@ -69,6 +69,8 @@ export {
69
69
  export type {
70
70
  CreateServerOptions,
71
71
  HandlerRouteDef,
72
+ RawRouteBuilder,
73
+ RawRouteInit,
72
74
  RouteDef,
73
75
  RouteGroup,
74
76
  ServerInstance,
@@ -371,7 +371,15 @@ export function createServerInstrumentation<Ctx>(
371
371
  },
372
372
  };
373
373
  },
374
- afterSend: ({ req, ctx, contract, response, error, durationMs }) => {
374
+ afterSend: ({
375
+ req,
376
+ ctx,
377
+ contract,
378
+ response,
379
+ error,
380
+ durationMs,
381
+ stages,
382
+ }) => {
375
383
  try {
376
384
  if (!port) return;
377
385
 
@@ -414,6 +422,7 @@ export function createServerInstrumentation<Ctx>(
414
422
  responseOwner,
415
423
  status: response.status,
416
424
  durationMs,
425
+ stages,
417
426
  details: {
418
427
  headers: requestHeadersToRecord(req.headers),
419
428
  route: {
@@ -0,0 +1,49 @@
1
+ import type { HttpContractConfig } from "../contracts/index.js";
2
+ import type {
3
+ HttpRequestLike,
4
+ HttpResponseLike,
5
+ MaybePromise,
6
+ } from "./http.js";
7
+
8
+ export const responseFinalizerHook = Symbol(
9
+ "@beignet/core/server/responseFinalizer",
10
+ );
11
+
12
+ export type ResponseFinalizerResponseOwner =
13
+ | "route"
14
+ | "framework"
15
+ | "transport";
16
+
17
+ export type ResponseFinalizerValidationState =
18
+ | "validated"
19
+ | "disabled"
20
+ | "not-applicable";
21
+
22
+ export type ResponseFinalizerHookArgs<Ctx> = {
23
+ req: HttpRequestLike;
24
+ ctx?: Ctx;
25
+ contract: HttpContractConfig;
26
+ path?: unknown;
27
+ query?: unknown;
28
+ headers?: unknown;
29
+ body?: unknown;
30
+ response: HttpResponseLike;
31
+ error?: unknown;
32
+ native?: boolean;
33
+ owner: ResponseFinalizerResponseOwner;
34
+ responseValidation: ResponseFinalizerValidationState;
35
+ };
36
+
37
+ export type ResponseFinalizerHook<Ctx> = (
38
+ args: ResponseFinalizerHookArgs<Ctx>,
39
+ ) => MaybePromise<void>;
40
+
41
+ export type ResponseFinalizerServerHook<Ctx> = {
42
+ [responseFinalizerHook]?: ResponseFinalizerHook<Ctx>;
43
+ };
44
+
45
+ export function getResponseFinalizerHook<Ctx>(
46
+ hook: unknown,
47
+ ): ResponseFinalizerHook<Ctx> | undefined {
48
+ return (hook as ResponseFinalizerServerHook<Ctx>)[responseFinalizerHook];
49
+ }
@@ -44,6 +44,21 @@ export function clearActiveRequestContext(): void {
44
44
  activeRequestContext.enterWith(undefined);
45
45
  }
46
46
 
47
+ /**
48
+ * Run a function inside a scoped ambient request context frame.
49
+ *
50
+ * Internal to the server runtime — not part of the public package surface.
51
+ * `server.runServiceContext(...)` uses this `AsyncLocalStorage.run` form
52
+ * instead of `enterWith` because resuming an `enterWith` frame across
53
+ * top-level await crashes Bun 1.3.x in plain scripts.
54
+ */
55
+ export function runWithActiveRequestContext<T>(
56
+ context: ActiveRequestContext,
57
+ fn: () => T,
58
+ ): T {
59
+ return activeRequestContext.run(context, fn);
60
+ }
61
+
47
62
  /**
48
63
  * Read the ambient request context, when one is active.
49
64
  */