@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
|
@@ -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"
|
|
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
|
-
*
|
|
77
|
-
*
|
|
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
|
|
214
|
-
* framework `TooManyRequests` app error
|
|
215
|
-
* `resetAt` details. The bucket key is
|
|
216
|
-
*
|
|
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
|
|
225
|
-
const getClientIp = (
|
|
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 },
|
|
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 },
|
|
317
|
+
defaultRateLimitKey({ ctx, req, scope }, (r) =>
|
|
318
|
+
getClientIp(r, contract.name),
|
|
319
|
+
);
|
|
267
320
|
|
|
268
321
|
await enforceRateLimit(ctx.ports, {
|
|
269
322
|
key,
|
package/src/server/http.ts
CHANGED
|
@@ -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
|
|
406
|
-
* before context creation;
|
|
407
|
-
*
|
|
408
|
-
*
|
|
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
|
|
456
|
+
* Runs after request parsing, context creation, and route hook resolution.
|
|
421
457
|
*/
|
|
422
458
|
beforeHandle?: BeforeHandleHook<Ctx>;
|
|
423
459
|
/**
|
package/src/server/index.ts
CHANGED
|
@@ -371,7 +371,15 @@ export function createServerInstrumentation<Ctx>(
|
|
|
371
371
|
},
|
|
372
372
|
};
|
|
373
373
|
},
|
|
374
|
-
afterSend: ({
|
|
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
|
*/
|