@beignet/core 0.0.49 → 0.0.50

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 (109) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +160 -6
  3. package/dist/events/index.d.ts +54 -5
  4. package/dist/events/index.d.ts.map +1 -1
  5. package/dist/events/index.js +183 -32
  6. package/dist/events/index.js.map +1 -1
  7. package/dist/idempotency/index.d.ts +7 -3
  8. package/dist/idempotency/index.d.ts.map +1 -1
  9. package/dist/idempotency/index.js +45 -12
  10. package/dist/idempotency/index.js.map +1 -1
  11. package/dist/mail/index.d.ts.map +1 -1
  12. package/dist/mail/index.js +6 -3
  13. package/dist/mail/index.js.map +1 -1
  14. package/dist/ports/best-effort-work.d.ts +21 -0
  15. package/dist/ports/best-effort-work.d.ts.map +1 -0
  16. package/dist/ports/best-effort-work.js +2 -0
  17. package/dist/ports/best-effort-work.js.map +1 -0
  18. package/dist/ports/events.d.ts +7 -5
  19. package/dist/ports/events.d.ts.map +1 -1
  20. package/dist/ports/index.d.ts +6 -1
  21. package/dist/ports/index.d.ts.map +1 -1
  22. package/dist/ports/index.js +1 -0
  23. package/dist/ports/index.js.map +1 -1
  24. package/dist/ports/testing.d.ts +15 -0
  25. package/dist/ports/testing.d.ts.map +1 -1
  26. package/dist/ports/testing.js +38 -0
  27. package/dist/ports/testing.js.map +1 -1
  28. package/dist/providers/provider.d.ts +8 -5
  29. package/dist/providers/provider.d.ts.map +1 -1
  30. package/dist/providers/provider.js.map +1 -1
  31. package/dist/server/hooks/cors.d.ts +2 -2
  32. package/dist/server/hooks/cors.d.ts.map +1 -1
  33. package/dist/server/hooks/cors.js +2 -1
  34. package/dist/server/hooks/cors.js.map +1 -1
  35. package/dist/server/hooks/logging.d.ts +2 -2
  36. package/dist/server/hooks/logging.d.ts.map +1 -1
  37. package/dist/server/hooks/logging.js.map +1 -1
  38. package/dist/server/hooks/security.d.ts +2 -2
  39. package/dist/server/hooks/security.d.ts.map +1 -1
  40. package/dist/server/hooks/security.js.map +1 -1
  41. package/dist/server/http.d.ts +21 -2
  42. package/dist/server/http.d.ts.map +1 -1
  43. package/dist/server/index.d.ts +4 -0
  44. package/dist/server/index.d.ts.map +1 -1
  45. package/dist/server/index.js +4 -0
  46. package/dist/server/index.js.map +1 -1
  47. package/dist/server/instrumentation.d.ts.map +1 -1
  48. package/dist/server/instrumentation.js +5 -3
  49. package/dist/server/instrumentation.js.map +1 -1
  50. package/dist/server/request-executor.d.ts.map +1 -1
  51. package/dist/server/request-executor.js +9 -0
  52. package/dist/server/request-executor.js.map +1 -1
  53. package/dist/server/request-preparation.d.ts.map +1 -1
  54. package/dist/server/request-preparation.js +20 -2
  55. package/dist/server/request-preparation.js.map +1 -1
  56. package/dist/server/response-finalization.d.ts +2 -2
  57. package/dist/server/response-finalization.d.ts.map +1 -1
  58. package/dist/server/response-finalization.js +25 -8
  59. package/dist/server/response-finalization.js.map +1 -1
  60. package/dist/server/route-matching.d.ts.map +1 -1
  61. package/dist/server/route-matching.js +12 -1
  62. package/dist/server/route-matching.js.map +1 -1
  63. package/dist/server/server-sent-events.d.ts +94 -0
  64. package/dist/server/server-sent-events.d.ts.map +1 -0
  65. package/dist/server/server-sent-events.js +275 -0
  66. package/dist/server/server-sent-events.js.map +1 -0
  67. package/dist/server/server.d.ts.map +1 -1
  68. package/dist/server/server.js +41 -22
  69. package/dist/server/server.js.map +1 -1
  70. package/dist/server/trusted-proxy-internal.d.ts +4 -0
  71. package/dist/server/trusted-proxy-internal.d.ts.map +1 -1
  72. package/dist/server/trusted-proxy-internal.js +20 -0
  73. package/dist/server/trusted-proxy-internal.js.map +1 -1
  74. package/dist/server/trusted-proxy.d.ts.map +1 -1
  75. package/dist/server/trusted-proxy.js +3 -8
  76. package/dist/server/trusted-proxy.js.map +1 -1
  77. package/dist/testing/index.d.ts +17 -0
  78. package/dist/testing/index.d.ts.map +1 -1
  79. package/dist/testing/index.js +6 -1
  80. package/dist/testing/index.js.map +1 -1
  81. package/package.json +1 -1
  82. package/skills/app-architecture/SKILL.md +23 -3
  83. package/src/events/index.ts +263 -38
  84. package/src/idempotency/index.ts +65 -17
  85. package/src/mail/index.ts +7 -3
  86. package/src/ports/best-effort-work.ts +21 -0
  87. package/src/ports/events.ts +9 -4
  88. package/src/ports/index.ts +9 -0
  89. package/src/ports/testing.ts +45 -0
  90. package/src/providers/provider.ts +8 -5
  91. package/src/server/hooks/cors.ts +11 -5
  92. package/src/server/hooks/logging.ts +6 -2
  93. package/src/server/hooks/security.ts +8 -4
  94. package/src/server/http.ts +23 -2
  95. package/src/server/index.ts +4 -0
  96. package/src/server/instrumentation.ts +12 -4
  97. package/src/server/request-executor.ts +14 -0
  98. package/src/server/request-preparation.ts +23 -3
  99. package/src/server/response-finalization.ts +51 -15
  100. package/src/server/route-matching.ts +24 -1
  101. package/src/server/server-sent-events.ts +415 -0
  102. package/src/server/server.ts +46 -22
  103. package/src/server/trusted-proxy-internal.ts +20 -0
  104. package/src/server/trusted-proxy.ts +6 -7
  105. package/src/testing/index.ts +30 -0
  106. package/dist/query-codec.d.ts +0 -27
  107. package/dist/query-codec.d.ts.map +0 -1
  108. package/dist/query-codec.js +0 -245
  109. package/dist/query-codec.js.map +0 -1
@@ -451,6 +451,17 @@ type CanonicalValue =
451
451
  | readonly CanonicalValue[]
452
452
  | { readonly [key: string]: CanonicalValue };
453
453
 
454
+ const CANONICAL_TYPE_KEY = "$beignet:idempotency-type";
455
+
456
+ function canonicalType(
457
+ type: "bigint" | "date" | "negative-zero" | "omitted",
458
+ value?: string,
459
+ ): CanonicalValue {
460
+ return {
461
+ [CANONICAL_TYPE_KEY]: value === undefined ? [type] : [type, value],
462
+ };
463
+ }
464
+
454
465
  type MemoryRecord = MemoryIdempotencyEntry & {
455
466
  replayTtlSec?: number;
456
467
  };
@@ -875,11 +886,15 @@ function canonicalize(
875
886
  return undefined;
876
887
  }
877
888
 
878
- if (
879
- value === null ||
880
- typeof value === "string" ||
881
- typeof value === "boolean"
882
- ) {
889
+ if (value === null) {
890
+ return null;
891
+ }
892
+
893
+ if (typeof value === "string") {
894
+ return value;
895
+ }
896
+
897
+ if (typeof value === "boolean") {
883
898
  return value;
884
899
  }
885
900
 
@@ -889,25 +904,54 @@ function canonicalize(
889
904
  "Cannot fingerprint non-finite numeric values.",
890
905
  );
891
906
  }
892
- return value;
907
+ return Object.is(value, -0) ? canonicalType("negative-zero") : value;
893
908
  }
894
909
 
895
910
  if (typeof value === "bigint") {
896
- return value.toString();
911
+ return canonicalType("bigint", value.toString());
897
912
  }
898
913
 
899
914
  if (value instanceof Date) {
900
- return value.toISOString();
915
+ if (!Number.isFinite(value.getTime())) {
916
+ throw new IdempotencyFingerprintError(
917
+ "Cannot fingerprint an invalid Date value.",
918
+ );
919
+ }
920
+ return canonicalType("date", value.toISOString());
901
921
  }
902
922
 
903
923
  if (Array.isArray(value)) {
904
- return value.map(
905
- (item, index) =>
906
- canonicalize(item, options, [...path, String(index)], seen) ?? null,
907
- );
924
+ if (seen.has(value)) {
925
+ throw new IdempotencyFingerprintError(
926
+ "Cannot fingerprint circular values.",
927
+ );
928
+ }
929
+ seen.add(value);
930
+ const result: CanonicalValue = Array.from(value, (item, index) => {
931
+ const nestedValue = canonicalize(
932
+ item,
933
+ options,
934
+ [...path, String(index)],
935
+ seen,
936
+ );
937
+ return nestedValue === undefined ? canonicalType("omitted") : nestedValue;
938
+ });
939
+ seen.delete(value);
940
+ return result;
908
941
  }
909
942
 
910
943
  if (typeof value === "object") {
944
+ const prototype = Object.getPrototypeOf(value);
945
+ if (prototype !== null && prototype !== Object.prototype) {
946
+ throw new IdempotencyFingerprintError(
947
+ "Cannot fingerprint unsupported object values. Use plain objects, arrays, Dates, and JSON primitives.",
948
+ );
949
+ }
950
+ if (Object.hasOwn(value, CANONICAL_TYPE_KEY)) {
951
+ throw new IdempotencyFingerprintError(
952
+ `Cannot fingerprint an object containing the reserved key "${CANONICAL_TYPE_KEY}".`,
953
+ );
954
+ }
911
955
  if (seen.has(value)) {
912
956
  throw new IdempotencyFingerprintError(
913
957
  "Cannot fingerprint circular values.",
@@ -915,7 +959,7 @@ function canonicalize(
915
959
  }
916
960
  seen.add(value);
917
961
 
918
- const result: Record<string, CanonicalValue> = {};
962
+ const result: Record<string, CanonicalValue> = Object.create(null);
919
963
  for (const key of Object.keys(value as Record<string, unknown>).sort()) {
920
964
  const nestedValue = canonicalize(
921
965
  (value as Record<string, unknown>)[key],
@@ -946,9 +990,13 @@ function bytesToHex(bytes: ArrayBuffer): string {
946
990
  /**
947
991
  * Create a SHA-256 fingerprint from a canonicalized value.
948
992
  *
949
- * Object keys are sorted, `undefined` and functions are omitted, `Date` values
950
- * become ISO strings, BigInts become strings, and circular or non-finite values
951
- * throw. Exact omit paths may be supplied as dotted strings or string arrays.
993
+ * Object keys are sorted, while non-JSON values carry reserved type tags so
994
+ * BigInts and Dates cannot collide with same-looking strings. Ordinary JSON
995
+ * inputs retain their existing canonical representation. `undefined` values
996
+ * and functions are omitted from objects and represented as omitted array
997
+ * positions. Circular or unsupported object values, reserved tag keys,
998
+ * invalid dates, and non-finite numbers throw. Exact omit paths may be supplied
999
+ * as dotted strings or string arrays.
952
1000
  */
953
1001
  export async function createIdempotencyFingerprint(
954
1002
  value: unknown,
@@ -961,7 +1009,7 @@ export async function createIdempotencyFingerprint(
961
1009
  }
962
1010
 
963
1011
  const canonical = canonicalize(value, options, [], new WeakSet());
964
- const json = JSON.stringify(canonical ?? null);
1012
+ const json = JSON.stringify(canonical ?? canonicalType("omitted"));
965
1013
  const digest = await globalThis.crypto.subtle.digest(
966
1014
  "SHA-256",
967
1015
  new TextEncoder().encode(json),
package/src/mail/index.ts CHANGED
@@ -403,13 +403,17 @@ export function createMemoryMailerProvider(
403
403
  onSend,
404
404
  },
405
405
  (delivery, durationMs) => {
406
+ const recipientCount =
407
+ delivery.message.to.length +
408
+ (delivery.message.cc?.length ?? 0) +
409
+ (delivery.message.bcc?.length ?? 0);
406
410
  instrumentation.custom({
407
411
  name: "mail.sent",
408
412
  label: "Mail sent",
409
- summary: delivery.message.subject,
413
+ summary: "Mail sent",
410
414
  details: {
411
- to: delivery.message.to,
412
- subject: delivery.message.subject,
415
+ provider: "memory",
416
+ recipientCount,
413
417
  id: delivery.id,
414
418
  durationMs,
415
419
  },
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Non-durable work scheduled outside the originating application operation.
3
+ */
4
+ export type BestEffortWork = () => Promise<void> | void;
5
+
6
+ /**
7
+ * Non-durable, best-effort follow-up work.
8
+ *
9
+ * Use this for follow-up work that may be lost without changing the outcome
10
+ * of the originating operation. Required or retryable work belongs in a job
11
+ * or durable outbox instead.
12
+ */
13
+ export interface BestEffortWorkPort {
14
+ /**
15
+ * Schedule work without waiting for it in the originating operation.
16
+ *
17
+ * Adapters should isolate scheduling and callback failures from the caller
18
+ * and report them through their configured error observer.
19
+ */
20
+ defer(work: BestEffortWork): void;
21
+ }
@@ -1,9 +1,13 @@
1
1
  import type {
2
2
  EventPayloadDef,
3
3
  EventPublishOptions,
4
+ EventSubscription,
4
5
  InferEventPayload as InferContractEventPayload,
5
6
  StandardSchema,
6
7
  } from "../events/index.js";
8
+
9
+ export type { EventSubscription } from "../events/index.js";
10
+
7
11
  import type {
8
12
  JobDef as ContractJobDef,
9
13
  InferJobPayload as InferContractJobPayload,
@@ -58,15 +62,16 @@ export type InferJobPayload<J extends JobDef> = InferContractJobPayload<J>;
58
62
  * const eventBus = createMemoryEventBus();
59
63
  *
60
64
  * // Subscribe to an event
61
- * const unsubscribe = eventBus.subscribe(UserRegistered, (payload) => {
65
+ * const subscription = eventBus.subscribe(UserRegistered, (payload) => {
62
66
  * console.log(`User registered: ${payload.email}`);
63
67
  * });
68
+ * await subscription.ready;
64
69
  *
65
70
  * // Publish an event
66
71
  * await eventBus.publish(UserRegistered, { userId: "123", email: "test@example.com" });
67
72
  *
68
73
  * // Unsubscribe when done
69
- * unsubscribe();
74
+ * await subscription.unsubscribe();
70
75
  * ```
71
76
  */
72
77
  export interface EventBusPort {
@@ -80,7 +85,7 @@ export interface EventBusPort {
80
85
  ): Promise<void> | void;
81
86
 
82
87
  /**
83
- * Subscribe to a domain event. Returns an unsubscribe function.
88
+ * Subscribe to a domain event. Returns initial readiness and cleanup.
84
89
  */
85
90
  subscribe<E extends DomainEventDef>(
86
91
  event: E,
@@ -88,7 +93,7 @@ export interface EventBusPort {
88
93
  payload: InferEventPayload<E>,
89
94
  options?: EventPublishOptions,
90
95
  ) => Promise<void> | void,
91
- ): () => void;
96
+ ): EventSubscription;
92
97
  }
93
98
 
94
99
  /**
@@ -8,6 +8,7 @@
8
8
  * - `definePorts` – helper to define a typed ports object
9
9
  * - `PortsContext` – a generic type that describes ctx objects that carry ports
10
10
  * - `EventBusPort` – interface for event bus implementations
11
+ * - `BestEffortWorkPort` – interface for non-durable follow-up work
11
12
  * - `JobDispatcherPort` – interface for job dispatch implementations
12
13
  * - `UnitOfWorkPort` – interface for app-owned transaction boundaries
13
14
  * - `OutboxPort` – interface for durable event/job delivery storage
@@ -468,6 +469,13 @@ export {
468
469
  requireUserId,
469
470
  TenantRequiredError,
470
471
  } from "./auth.js";
472
+ /**
473
+ * Best-effort work port exports.
474
+ */
475
+ export type {
476
+ BestEffortWork,
477
+ BestEffortWorkPort,
478
+ } from "./best-effort-work.js";
471
479
  /**
472
480
  * Ports builder exports.
473
481
  */
@@ -498,6 +506,7 @@ export { createFrozenClock, createSystemClock } from "./clock.js";
498
506
  export type {
499
507
  DomainEventDef,
500
508
  EventBusPort,
509
+ EventSubscription,
501
510
  InferEventPayload,
502
511
  InferJobPayload,
503
512
  JobDef,
@@ -38,6 +38,7 @@ import {
38
38
  createTenant,
39
39
  createUserActor,
40
40
  } from "./audit.js";
41
+ import type { BestEffortWork, BestEffortWorkPort } from "./best-effort-work.js";
41
42
  import type { EventBusPort, JobDef, JobDispatcherPort } from "./events.js";
42
43
  import {
43
44
  type CreateGateOptions,
@@ -114,6 +115,50 @@ export function createRecordingEventBus(): {
114
115
  return { bus, events };
115
116
  }
116
117
 
118
+ /**
119
+ * Recording best-effort-work adapter for deterministic tests.
120
+ *
121
+ * Calls to `defer(...)` append work without running it. `flush()` runs the
122
+ * currently pending batch in FIFO order, then rejects with the callback error
123
+ * or an `AggregateError` after every callback in that batch has been attempted.
124
+ * Work deferred by a running callback remains pending for the next flush so
125
+ * one flush stays bounded.
126
+ */
127
+ export function createRecordingBestEffortWork(): {
128
+ bestEffortWork: BestEffortWorkPort;
129
+ pending: BestEffortWork[];
130
+ flush(): Promise<void>;
131
+ } {
132
+ const pending: BestEffortWork[] = [];
133
+
134
+ return {
135
+ bestEffortWork: {
136
+ defer(work) {
137
+ pending.push(work);
138
+ },
139
+ },
140
+ pending,
141
+ async flush() {
142
+ const batch = pending.splice(0);
143
+ const errors: unknown[] = [];
144
+ for (const work of batch) {
145
+ try {
146
+ await work();
147
+ } catch (error) {
148
+ errors.push(error);
149
+ }
150
+ }
151
+
152
+ if (errors.length === 1) {
153
+ throw errors[0];
154
+ }
155
+ if (errors.length > 1) {
156
+ throw new AggregateError(errors, "Best-effort work batch failed.");
157
+ }
158
+ },
159
+ };
160
+ }
161
+
117
162
  /**
118
163
  * A job dispatch captured by `createRecordingJobDispatcher(...)`.
119
164
  */
@@ -55,9 +55,11 @@ export type MaybePromise<T> = T | Promise<T>;
55
55
  /**
56
56
  * Late-bound service context factory exposed to providers.
57
57
  *
58
- * Calling it before all providers have started throws, so providers should
59
- * only invoke it from runtime entrypoints such as job dispatch, listeners, or
60
- * scheduled work.
58
+ * Calling it during provider setup or after provider shutdown throws. Start
59
+ * and stop hooks may invoke it after every provider has contributed ports and
60
+ * the server has verified that no deferred port remains unbound. Runtime
61
+ * entrypoints such as job dispatch, listeners, or scheduled work can close
62
+ * over it for later use.
61
63
  *
62
64
  * App-local providers can type the factory by declaring `Context` and
63
65
  * `ServiceInput` through the curried `createProvider<Requires, Context,
@@ -222,8 +224,9 @@ export interface ServiceProvider<
222
224
  * @param ctx.ports - Ports contributed by previous providers
223
225
  * @param ctx.config - Validated config (if config was defined), or undefined
224
226
  * @param ctx.createServiceContext - Late-bound service context factory.
225
- * Throws until all providers have started, so call it lazily from runtime
226
- * entrypoints such as job dispatchers and event listeners.
227
+ * Throws during setup. Start and stop hooks may invoke it after all provider
228
+ * ports have been contributed and validated; runtime entrypoints such as job
229
+ * dispatchers and event listeners may call it lazily afterward.
227
230
  */
228
231
  setup(ctx: {
229
232
  ports: Readonly<Ports>;
@@ -2,7 +2,11 @@
2
2
  * CORS hook utilities for @beignet/core/server
3
3
  */
4
4
 
5
- import type { HttpRequestLike, ServerHook } from "../types.js";
5
+ import type {
6
+ HttpRequestLike,
7
+ HttpResponseHeaders,
8
+ ServerHook,
9
+ } from "../types.js";
6
10
 
7
11
  /**
8
12
  * CORS configuration for `createCorsHooks(...)`.
@@ -55,10 +59,12 @@ function resolveCorsConfig(config: CorsConfig): Required<CorsConfig> {
55
59
  return resolved;
56
60
  }
57
61
 
58
- function appendVaryOrigin(headers: Record<string, string>): void {
62
+ function appendVaryOrigin(headers: HttpResponseHeaders): void {
59
63
  const varyKey =
60
64
  Object.keys(headers).find((key) => key.toLowerCase() === "vary") ?? "Vary";
61
- const current = headers[varyKey];
65
+ const currentValue = headers[varyKey];
66
+ const current =
67
+ typeof currentValue === "string" ? currentValue : currentValue?.join(", ");
62
68
  const values = current?.split(",").map((value) => value.trim().toLowerCase());
63
69
  if (values?.includes("origin")) return;
64
70
 
@@ -72,7 +78,7 @@ function appendVaryOrigin(headers: Record<string, string>): void {
72
78
  * when cookies or authorization headers are allowed cross-origin.
73
79
  */
74
80
  export function applyCorsHeaders(
75
- headers: Record<string, string>,
81
+ headers: HttpResponseHeaders,
76
82
  req: HttpRequestLike,
77
83
  corsConfig: CorsConfig,
78
84
  ): void {
@@ -121,7 +127,7 @@ export function createCorsHooks<Ctx>(config: CorsConfig): ServerHook<Ctx> {
121
127
  ) {
122
128
  return undefined;
123
129
  }
124
- const headers: Record<string, string> = {};
130
+ const headers: HttpResponseHeaders = {};
125
131
  applyCorsHeaders(headers, req, corsConfig);
126
132
  return {
127
133
  status: 204,
@@ -7,7 +7,11 @@ import {
7
7
  resolveTrustedRequest,
8
8
  type TrustedRequestInfo,
9
9
  } from "../trusted-proxy.js";
10
- import type { HttpRequestLike, ServerHook } from "../types.js";
10
+ import type {
11
+ HttpRequestLike,
12
+ HttpResponseHeaders,
13
+ ServerHook,
14
+ } from "../types.js";
11
15
  import { getRequestIdFromContext } from "./utils.js";
12
16
 
13
17
  /**
@@ -60,7 +64,7 @@ export interface LoggingConfig<Ctx> {
60
64
  ctx?: Ctx;
61
65
  req: HttpRequestLike;
62
66
  requestInfo: TrustedRequestInfo;
63
- res: { status: number; headers: Record<string, string> };
67
+ res: { status: number; headers: HttpResponseHeaders };
64
68
  durationMs: number;
65
69
  contract?: HttpContractConfig;
66
70
  error?: unknown;
@@ -9,7 +9,11 @@ import {
9
9
  type TrustedProxyConfig,
10
10
  type TrustedProxyOptions,
11
11
  } from "../trusted-proxy.js";
12
- import type { HttpRequestLike, ServerHook } from "../types.js";
12
+ import type {
13
+ HttpRequestLike,
14
+ HttpResponseHeaders,
15
+ ServerHook,
16
+ } from "../types.js";
13
17
 
14
18
  /**
15
19
  * Strict-Transport-Security configuration.
@@ -180,7 +184,7 @@ const DEFAULT_CSRF_HEADER = "x-csrf-token";
180
184
  const DEFAULT_CSRF_COOKIE = "beignet.csrf";
181
185
 
182
186
  function headerKey(
183
- headers: Record<string, string>,
187
+ headers: HttpResponseHeaders,
184
188
  name: string,
185
189
  ): string | undefined {
186
190
  const lowerName = name.toLowerCase();
@@ -188,7 +192,7 @@ function headerKey(
188
192
  }
189
193
 
190
194
  function setHeaderIfMissing(
191
- headers: Record<string, string>,
195
+ headers: HttpResponseHeaders,
192
196
  name: string,
193
197
  value: string | false | undefined,
194
198
  ): void {
@@ -217,7 +221,7 @@ function formatStrictTransportSecurity(
217
221
  * provide more specific policies.
218
222
  */
219
223
  export function applySecurityHeaders(
220
- headers: Record<string, string>,
224
+ headers: HttpResponseHeaders,
221
225
  options: SecurityHeadersOptions = {},
222
226
  ): void {
223
227
  setHeaderIfMissing(
@@ -29,6 +29,14 @@ export interface HttpRequestLike {
29
29
  * Request headers.
30
30
  */
31
31
  headers: Headers;
32
+ /**
33
+ * Abort signal for the request lifecycle when the platform exposes one.
34
+ *
35
+ * Streaming handlers should pass this to resources that must close when
36
+ * the client disconnects. Adapters without cancellation support may omit
37
+ * it.
38
+ */
39
+ signal?: AbortSignal;
32
40
  /**
33
41
  * The platform request when an adapter has one available.
34
42
  *
@@ -62,6 +70,19 @@ export interface HttpRequestLike {
62
70
  clone?(): HttpRequestLike;
63
71
  }
64
72
 
73
+ /**
74
+ * Header values accepted by a framework-neutral Beignet response.
75
+ *
76
+ * Use an array when the field must be emitted more than once, such as
77
+ * `Set-Cookie`. Adapters append every array item as a separate field value.
78
+ */
79
+ export type HttpResponseHeaderValue = string | readonly string[];
80
+
81
+ /**
82
+ * Framework-neutral response headers.
83
+ */
84
+ export type HttpResponseHeaders = Record<string, HttpResponseHeaderValue>;
85
+
65
86
  /**
66
87
  * Framework-neutral response object returned by route handlers and hooks.
67
88
  */
@@ -71,9 +92,9 @@ export interface HttpResponseLike {
71
92
  */
72
93
  status: number;
73
94
  /**
74
- * Response headers.
95
+ * Response headers. Array values are emitted as repeated header fields.
75
96
  */
76
- headers?: Record<string, string>;
97
+ headers?: HttpResponseHeaders;
77
98
  /**
78
99
  * JSON-serializable body or an adapter-specific body value.
79
100
  */
@@ -95,6 +95,10 @@ export {
95
95
  * Server context blueprint declaration helper.
96
96
  */
97
97
  export { defineServerContext } from "./server-context.js";
98
+ /**
99
+ * Portable Server-Sent Events response helper.
100
+ */
101
+ export * from "./server-sent-events.js";
98
102
  /**
99
103
  * Trusted proxy request metadata helpers.
100
104
  */
@@ -16,7 +16,12 @@ import {
16
16
  type TraceContext,
17
17
  type TracingPort,
18
18
  } from "../tracing/index.js";
19
- import type { HttpRequestLike, HttpResponseLike, ServerHook } from "./http.js";
19
+ import type {
20
+ HttpRequestLike,
21
+ HttpResponseHeaders,
22
+ HttpResponseLike,
23
+ ServerHook,
24
+ } from "./http.js";
20
25
  import {
21
26
  clearActiveRequestContext,
22
27
  enterActiveRequestContext,
@@ -205,17 +210,20 @@ function requestHeadersToRecord(headers: Headers): Record<string, string> {
205
210
  }
206
211
 
207
212
  function getResponseHeader(
208
- headers: Record<string, string> | undefined,
213
+ headers: HttpResponseHeaders | undefined,
209
214
  name: string,
210
215
  ): string | undefined {
211
216
  if (!headers) return undefined;
212
217
  const direct = headers[name];
213
- if (direct !== undefined) return direct;
218
+ if (direct !== undefined) {
219
+ return typeof direct === "string" ? direct : direct[0];
220
+ }
214
221
  const normalized = name.toLowerCase();
215
222
  const entry = Object.entries(headers).find(
216
223
  ([key]) => key.toLowerCase() === normalized,
217
224
  );
218
- return entry?.[1];
225
+ const value = entry?.[1];
226
+ return typeof value === "string" ? value : value?.[0];
219
227
  }
220
228
 
221
229
  function getResponseOwner(
@@ -82,6 +82,7 @@ import {
82
82
  PathDecodeError,
83
83
  } from "./route-matching.js";
84
84
  import type { TrustedRequestInfo } from "./trusted-proxy.js";
85
+ import { InvalidRequestUrlError } from "./trusted-proxy-internal.js";
85
86
 
86
87
  function withoutHeadResponseBody(
87
88
  response: HttpResponse,
@@ -281,6 +282,19 @@ export function createRequestExecutor<
281
282
  };
282
283
  }
283
284
 
285
+ if (currentError instanceof InvalidRequestUrlError) {
286
+ return {
287
+ ctx,
288
+ response: errorResponse(
289
+ 400,
290
+ "INVALID_REQUEST_URL",
291
+ "Malformed request URL",
292
+ ),
293
+ error: currentError,
294
+ owner: "framework",
295
+ };
296
+ }
297
+
284
298
  if (isAppError(currentError)) {
285
299
  return {
286
300
  ctx,
@@ -41,6 +41,17 @@ type RequestValidationLocation = "query" | "path" | "headers" | "body";
41
41
 
42
42
  const DEFAULT_REQUEST_BODY_MAX_BYTES = 1024 * 1024;
43
43
 
44
+ function cancelRequestBody(
45
+ source: { cancel(reason?: unknown): Promise<void> },
46
+ reason: unknown,
47
+ ): void {
48
+ try {
49
+ void source.cancel(reason).catch(() => {});
50
+ } catch {
51
+ // Cancellation is best-effort and must not replace the 413 response.
52
+ }
53
+ }
54
+
44
55
  class RequestBodyTooLargeError extends Error {
45
56
  readonly maxBytes: number;
46
57
  readonly actualBytes?: number;
@@ -208,9 +219,16 @@ async function readLimitedRequestText(
208
219
  req: HttpRequestLike,
209
220
  maxBytes: number,
210
221
  ): Promise<string> {
211
- assertContentLengthWithinLimit(req.headers, maxBytes);
212
-
213
222
  const body = req.raw?.body;
223
+ try {
224
+ assertContentLengthWithinLimit(req.headers, maxBytes);
225
+ } catch (error) {
226
+ if (body) {
227
+ cancelRequestBody(body, error);
228
+ }
229
+ throw error;
230
+ }
231
+
214
232
  if (!body) {
215
233
  const text = await req.text();
216
234
  const actualBytes = new TextEncoder().encode(text).byteLength;
@@ -231,7 +249,9 @@ async function readLimitedRequestText(
231
249
  if (result.done) break;
232
250
  received += result.value.byteLength;
233
251
  if (received > maxBytes) {
234
- throw new RequestBodyTooLargeError(maxBytes, received);
252
+ const error = new RequestBodyTooLargeError(maxBytes, received);
253
+ cancelRequestBody(reader, error);
254
+ throw error;
235
255
  }
236
256
  text += decoder.decode(result.value, { stream: true });
237
257
  }