@beignet/core 0.0.48 → 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 (173) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +224 -20
  3. package/dist/client/client.d.ts +0 -2
  4. package/dist/client/client.d.ts.map +1 -1
  5. package/dist/client/client.js +28 -25
  6. package/dist/client/client.js.map +1 -1
  7. package/dist/contracts/contract-builder.d.ts +7 -2
  8. package/dist/contracts/contract-builder.d.ts.map +1 -1
  9. package/dist/contracts/contract-builder.js +20 -2
  10. package/dist/contracts/contract-builder.js.map +1 -1
  11. package/dist/contracts/contract-group.d.ts.map +1 -1
  12. package/dist/contracts/contract-group.js +1 -0
  13. package/dist/contracts/contract-group.js.map +1 -1
  14. package/dist/contracts/contract-like.d.ts +2 -0
  15. package/dist/contracts/contract-like.d.ts.map +1 -1
  16. package/dist/contracts/contract-like.js +27 -1
  17. package/dist/contracts/contract-like.js.map +1 -1
  18. package/dist/contracts/index.d.ts +4 -0
  19. package/dist/contracts/index.d.ts.map +1 -1
  20. package/dist/contracts/index.js +4 -0
  21. package/dist/contracts/index.js.map +1 -1
  22. package/dist/contracts/query-transport.d.ts +126 -0
  23. package/dist/contracts/query-transport.d.ts.map +1 -0
  24. package/dist/contracts/query-transport.js +406 -0
  25. package/dist/contracts/query-transport.js.map +1 -0
  26. package/dist/contracts/schema-shape.d.ts +11 -0
  27. package/dist/contracts/schema-shape.d.ts.map +1 -1
  28. package/dist/contracts/schema-shape.js +13 -0
  29. package/dist/contracts/schema-shape.js.map +1 -1
  30. package/dist/contracts/types.d.ts +5 -0
  31. package/dist/contracts/types.d.ts.map +1 -1
  32. package/dist/contracts/types.js.map +1 -1
  33. package/dist/events/index.d.ts +54 -5
  34. package/dist/events/index.d.ts.map +1 -1
  35. package/dist/events/index.js +183 -32
  36. package/dist/events/index.js.map +1 -1
  37. package/dist/idempotency/index.d.ts +7 -3
  38. package/dist/idempotency/index.d.ts.map +1 -1
  39. package/dist/idempotency/index.js +45 -12
  40. package/dist/idempotency/index.js.map +1 -1
  41. package/dist/mail/index.d.ts.map +1 -1
  42. package/dist/mail/index.js +6 -3
  43. package/dist/mail/index.js.map +1 -1
  44. package/dist/openapi/index.d.ts +8 -0
  45. package/dist/openapi/index.d.ts.map +1 -1
  46. package/dist/openapi/index.js +79 -5
  47. package/dist/openapi/index.js.map +1 -1
  48. package/dist/outbox/index.d.ts +8 -5
  49. package/dist/outbox/index.d.ts.map +1 -1
  50. package/dist/outbox/index.js +17 -3
  51. package/dist/outbox/index.js.map +1 -1
  52. package/dist/ports/best-effort-work.d.ts +21 -0
  53. package/dist/ports/best-effort-work.d.ts.map +1 -0
  54. package/dist/ports/best-effort-work.js +2 -0
  55. package/dist/ports/best-effort-work.js.map +1 -0
  56. package/dist/ports/cache.d.ts +9 -1
  57. package/dist/ports/cache.d.ts.map +1 -1
  58. package/dist/ports/cache.js +20 -5
  59. package/dist/ports/cache.js.map +1 -1
  60. package/dist/ports/events.d.ts +7 -5
  61. package/dist/ports/events.d.ts.map +1 -1
  62. package/dist/ports/index.d.ts +7 -2
  63. package/dist/ports/index.d.ts.map +1 -1
  64. package/dist/ports/index.js +2 -1
  65. package/dist/ports/index.js.map +1 -1
  66. package/dist/ports/testing.d.ts +15 -0
  67. package/dist/ports/testing.d.ts.map +1 -1
  68. package/dist/ports/testing.js +38 -0
  69. package/dist/ports/testing.js.map +1 -1
  70. package/dist/providers/provider.d.ts +8 -5
  71. package/dist/providers/provider.d.ts.map +1 -1
  72. package/dist/providers/provider.js.map +1 -1
  73. package/dist/server/hooks/cors.d.ts +2 -2
  74. package/dist/server/hooks/cors.d.ts.map +1 -1
  75. package/dist/server/hooks/cors.js +2 -1
  76. package/dist/server/hooks/cors.js.map +1 -1
  77. package/dist/server/hooks/logging.d.ts +2 -2
  78. package/dist/server/hooks/logging.d.ts.map +1 -1
  79. package/dist/server/hooks/logging.js.map +1 -1
  80. package/dist/server/hooks/rate-limit.d.ts +16 -8
  81. package/dist/server/hooks/rate-limit.d.ts.map +1 -1
  82. package/dist/server/hooks/rate-limit.js +31 -17
  83. package/dist/server/hooks/rate-limit.js.map +1 -1
  84. package/dist/server/hooks/security.d.ts +2 -2
  85. package/dist/server/hooks/security.d.ts.map +1 -1
  86. package/dist/server/hooks/security.js.map +1 -1
  87. package/dist/server/http.d.ts +21 -2
  88. package/dist/server/http.d.ts.map +1 -1
  89. package/dist/server/index.d.ts +4 -0
  90. package/dist/server/index.d.ts.map +1 -1
  91. package/dist/server/index.js +4 -0
  92. package/dist/server/index.js.map +1 -1
  93. package/dist/server/instrumentation.d.ts.map +1 -1
  94. package/dist/server/instrumentation.js +5 -3
  95. package/dist/server/instrumentation.js.map +1 -1
  96. package/dist/server/request-executor.d.ts.map +1 -1
  97. package/dist/server/request-executor.js +18 -9
  98. package/dist/server/request-executor.js.map +1 -1
  99. package/dist/server/request-preparation.d.ts.map +1 -1
  100. package/dist/server/request-preparation.js +31 -11
  101. package/dist/server/request-preparation.js.map +1 -1
  102. package/dist/server/response-finalization.d.ts +2 -2
  103. package/dist/server/response-finalization.d.ts.map +1 -1
  104. package/dist/server/response-finalization.js +25 -8
  105. package/dist/server/response-finalization.js.map +1 -1
  106. package/dist/server/route-matching.d.ts.map +1 -1
  107. package/dist/server/route-matching.js +12 -1
  108. package/dist/server/route-matching.js.map +1 -1
  109. package/dist/server/server-sent-events.d.ts +94 -0
  110. package/dist/server/server-sent-events.d.ts.map +1 -0
  111. package/dist/server/server-sent-events.js +275 -0
  112. package/dist/server/server-sent-events.js.map +1 -0
  113. package/dist/server/server.d.ts.map +1 -1
  114. package/dist/server/server.js +43 -22
  115. package/dist/server/server.js.map +1 -1
  116. package/dist/server/trusted-proxy-internal.d.ts +4 -0
  117. package/dist/server/trusted-proxy-internal.d.ts.map +1 -1
  118. package/dist/server/trusted-proxy-internal.js +20 -0
  119. package/dist/server/trusted-proxy-internal.js.map +1 -1
  120. package/dist/server/trusted-proxy.d.ts.map +1 -1
  121. package/dist/server/trusted-proxy.js +3 -8
  122. package/dist/server/trusted-proxy.js.map +1 -1
  123. package/dist/server/use-case-route.d.ts +8 -5
  124. package/dist/server/use-case-route.d.ts.map +1 -1
  125. package/dist/server/use-case-route.js +44 -17
  126. package/dist/server/use-case-route.js.map +1 -1
  127. package/dist/testing/index.d.ts +17 -0
  128. package/dist/testing/index.d.ts.map +1 -1
  129. package/dist/testing/index.js +6 -1
  130. package/dist/testing/index.js.map +1 -1
  131. package/package.json +3 -3
  132. package/skills/app-architecture/SKILL.md +50 -4
  133. package/src/client/client.ts +29 -28
  134. package/src/contracts/contract-builder.ts +32 -2
  135. package/src/contracts/contract-group.ts +1 -0
  136. package/src/contracts/contract-like.ts +40 -1
  137. package/src/contracts/index.ts +23 -0
  138. package/src/contracts/query-transport.ts +697 -0
  139. package/src/contracts/schema-shape.ts +24 -0
  140. package/src/contracts/types.ts +5 -0
  141. package/src/events/index.ts +263 -38
  142. package/src/idempotency/index.ts +65 -17
  143. package/src/mail/index.ts +7 -3
  144. package/src/openapi/index.ts +126 -2
  145. package/src/outbox/index.ts +26 -5
  146. package/src/ports/best-effort-work.ts +21 -0
  147. package/src/ports/cache.ts +29 -7
  148. package/src/ports/events.ts +9 -4
  149. package/src/ports/index.ts +10 -1
  150. package/src/ports/testing.ts +45 -0
  151. package/src/providers/provider.ts +8 -5
  152. package/src/server/hooks/cors.ts +11 -5
  153. package/src/server/hooks/logging.ts +6 -2
  154. package/src/server/hooks/rate-limit.ts +50 -24
  155. package/src/server/hooks/security.ts +8 -4
  156. package/src/server/http.ts +23 -2
  157. package/src/server/index.ts +4 -0
  158. package/src/server/instrumentation.ts +12 -4
  159. package/src/server/request-executor.ts +31 -9
  160. package/src/server/request-preparation.ts +45 -12
  161. package/src/server/response-finalization.ts +51 -15
  162. package/src/server/route-matching.ts +24 -1
  163. package/src/server/server-sent-events.ts +415 -0
  164. package/src/server/server.ts +48 -22
  165. package/src/server/trusted-proxy-internal.ts +20 -0
  166. package/src/server/trusted-proxy.ts +6 -7
  167. package/src/server/use-case-route.ts +62 -23
  168. package/src/testing/index.ts +30 -0
  169. package/dist/query-codec.d.ts +0 -3
  170. package/dist/query-codec.d.ts.map +0 -1
  171. package/dist/query-codec.js +0 -110
  172. package/dist/query-codec.js.map +0 -1
  173. package/src/query-codec.ts +0 -130
@@ -4,7 +4,11 @@
4
4
 
5
5
  import type { RateLimitScope } from "../../contracts/index.js";
6
6
  import { AppError, httpErrors } from "../../errors/index.js";
7
- import type { ActivityActor, RateLimitPort } from "../../ports/index.js";
7
+ import {
8
+ type ActivityActor,
9
+ AuthUnauthorizedError,
10
+ type RateLimitPort,
11
+ } from "../../ports/index.js";
8
12
  import {
9
13
  createProviderInstrumentation,
10
14
  type ProviderInstrumentationTarget,
@@ -38,8 +42,9 @@ type EarlyRateLimitScope = Exclude<RateLimitScope, "user">;
38
42
  * Strategy for resolving the client IP used by `ip`-scoped limits.
39
43
  *
40
44
  * - `"none"`: do not trust request headers for IP resolution. Every request
41
- * shares one `ip:unknown` bucket; this is the explicit opt-out for apps
42
- * that declare `ip` scopes without a trusted client-IP source.
45
+ * to one contract shares that contract's unknown-client bucket; this is the
46
+ * explicit opt-out for apps that declare `ip` scopes without a trusted
47
+ * client-IP source.
43
48
  * - `"x-forwarded-for-last"`: the last `x-forwarded-for` entry. Use this only
44
49
  * when the app is always behind a trusted reverse proxy that appends the
45
50
  * socket address.
@@ -59,7 +64,9 @@ export interface RateLimitOptions<Ctx> {
59
64
  /**
60
65
  * Build a rate-limit key after context exists.
61
66
  *
62
- * This is used for user-scoped limits and any late key strategy.
67
+ * This is used for user-scoped limits and any late key strategy. The
68
+ * returned value is the complete key and is not automatically namespaced by
69
+ * contract.
63
70
  */
64
71
  key?: (args: {
65
72
  ctx: Ctx;
@@ -69,7 +76,8 @@ export interface RateLimitOptions<Ctx> {
69
76
  /**
70
77
  * Build a rate-limit key before request parsing and context creation.
71
78
  *
72
- * This is used for global and IP-scoped limits.
79
+ * This is used for global and IP-scoped limits. The returned value is the
80
+ * complete key and is not automatically namespaced by contract.
73
81
  */
74
82
  earlyKey?: (args: {
75
83
  req: HttpRequestLike;
@@ -124,16 +132,23 @@ function ipSourceConfigurationError(contractNames: string): Error {
124
132
  `createRateLimitHooks(...) has no client IP source configured, but contract(s) ${contractNames} declare an "ip"-scoped rate limit. ` +
125
133
  `Set trustedProxy.clientIp or ipSource to a header source written by a trusted edge, ` +
126
134
  `for example "x-forwarded-for-last", "x-forwarded-for-first", "x-real-ip", "cf-connecting-ip", or a custom function, ` +
127
- `or "none" to explicitly accept one shared ip:unknown bucket for all clients.`,
135
+ `or "none" to explicitly accept one shared unknown-client bucket per contract.`,
128
136
  );
129
137
  }
130
138
 
131
- function emitUserKey(userId: string): string {
132
- return `user:${userId}`;
139
+ function namespaceContractKey(contractName: string, key: string): string {
140
+ const encodedContractName = contractName
141
+ .replaceAll("%", "%25")
142
+ .replaceAll(":", "%3A");
143
+ return `contract:${encodedContractName}:${key}`;
144
+ }
145
+
146
+ function emitUserKey(contractName: string, userId: string): string {
147
+ return namespaceContractKey(contractName, `user:${userId}`);
133
148
  }
134
149
 
135
- function emitIpKey(ip: string): string {
136
- return `ip:${ip}`;
150
+ function emitIpKey(contractName: string, ip: string): string {
151
+ return namespaceContractKey(contractName, `ip:${ip}`);
137
152
  }
138
153
 
139
154
  function defaultRateLimitKey<Ctx extends CtxWithRateLimit>(
@@ -141,36 +156,41 @@ function defaultRateLimitKey<Ctx extends CtxWithRateLimit>(
141
156
  ctx: Ctx;
142
157
  req: HttpRequestLike;
143
158
  scope: RateLimitScope;
159
+ contractName: string;
144
160
  },
145
161
  getClientIp: (req: HttpRequestLike) => string | undefined,
146
162
  ): string {
147
- const { ctx, req, scope } = args;
163
+ const { contractName, ctx, req, scope } = args;
148
164
 
149
- if (scope === "user" && ctx.actor?.type === "user" && ctx.actor.id) {
150
- return emitUserKey(ctx.actor.id);
165
+ if (scope === "user") {
166
+ if (ctx.actor?.type !== "user" || !ctx.actor.id) {
167
+ throw new AuthUnauthorizedError();
168
+ }
169
+ return emitUserKey(contractName, ctx.actor.id);
151
170
  }
152
171
 
153
172
  if (scope === "ip") {
154
173
  const ip = getClientIp(req) || "unknown";
155
- return emitIpKey(ip);
174
+ return emitIpKey(contractName, ip);
156
175
  }
157
176
 
158
- return "global";
177
+ return namespaceContractKey(contractName, "global");
159
178
  }
160
179
 
161
180
  function defaultEarlyRateLimitKey(
162
181
  args: {
163
182
  req: HttpRequestLike;
164
183
  scope: EarlyRateLimitScope;
184
+ contractName: string;
165
185
  },
166
186
  getClientIp: (req: HttpRequestLike) => string | undefined,
167
187
  ): string {
168
188
  if (args.scope === "ip") {
169
189
  const ip = getClientIp(args.req) || "unknown";
170
- return emitIpKey(ip);
190
+ return emitIpKey(args.contractName, ip);
171
191
  }
172
192
 
173
- return "global";
193
+ return namespaceContractKey(args.contractName, "global");
174
194
  }
175
195
 
176
196
  async function enforceRateLimit(
@@ -233,7 +253,10 @@ async function enforceRateLimit(
233
253
  * The hook reads `contract.metadata.rateLimit`. Global and IP-scoped limits run
234
254
  * in `onRequest` before context creation; user-scoped limits run in
235
255
  * `beforeHandle` after route hooks have resolved identity and `ctx.actor` is
236
- * available. Exceeded limits throw the framework `TooManyRequests` app error
256
+ * available. Default keys include the contract name so unrelated contracts do
257
+ * not share counters. A user-scoped limit without a resolved user actor fails
258
+ * with `AuthUnauthorizedError` instead of falling back to a global bucket.
259
+ * Exceeded limits throw the framework `TooManyRequests` app error
237
260
  * with `scope`, `retryAfterSeconds`, and `resetAt` details, and the 429
238
261
  * response carries a `Retry-After` header when the limiter reports a reset
239
262
  * time. The bucket key is
@@ -243,11 +266,12 @@ async function enforceRateLimit(
243
266
  * `ip`-scoped limits require an explicit `trustedProxy.clientIp`, `ipSource`,
244
267
  * or custom `earlyKey`: the hook's `validate` phase fails `createServer(...)`
245
268
  * startup when a registered contract declares an `ip` scope without one,
246
- * instead of silently collapsing all clients into a shared `ip:unknown` bucket.
269
+ * instead of silently collapsing all clients into one shared bucket.
247
270
  * Contracts added later through `server.route(...)` are not visible to
248
271
  * `validate`, so enforcing an `ip`-scoped limit without a client-IP source
249
272
  * throws the same configuration error at request time as a backstop. Pass
250
- * `ipSource: "none"` to explicitly opt in to the shared `ip:unknown` bucket.
273
+ * `ipSource: "none"` to explicitly opt in to one unknown-client bucket per
274
+ * contract.
251
275
  *
252
276
  * @param options - Optional key builders and client-IP source.
253
277
  * @returns A server hook backed by `ctx.ports.rateLimit`.
@@ -309,8 +333,9 @@ export function createRateLimitHooks<Ctx extends CtxWithRateLimit>(
309
333
 
310
334
  const key =
311
335
  options.earlyKey?.({ req, scope }) ??
312
- defaultEarlyRateLimitKey({ req, scope }, (r) =>
313
- getClientIp(r, requestInfo, contract.name),
336
+ defaultEarlyRateLimitKey(
337
+ { req, scope, contractName: contract.name },
338
+ (r) => getClientIp(r, requestInfo, contract.name),
314
339
  );
315
340
 
316
341
  await enforceRateLimit(ports, {
@@ -335,8 +360,9 @@ export function createRateLimitHooks<Ctx extends CtxWithRateLimit>(
335
360
 
336
361
  const key =
337
362
  options.key?.({ ctx, req, scope }) ??
338
- defaultRateLimitKey({ ctx, req, scope }, (r) =>
339
- getClientIp(r, requestInfo, contract.name),
363
+ defaultRateLimitKey(
364
+ { ctx, req, scope, contractName: contract.name },
365
+ (r) => getClientIp(r, requestInfo, contract.name),
340
366
  );
341
367
 
342
368
  await enforceRateLimit(ctx.ports, {
@@ -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,
@@ -949,17 +963,25 @@ export function createRequestExecutor<
949
963
  error,
950
964
  owner: "framework",
951
965
  };
952
- finalResponse = normalizeHttpResponse(result.response);
953
- finalError = result.error;
954
- finalOwner = responseOwnerFor(finalResponse, result.owner);
955
- result = await applyTransformHooks(result, true);
956
- finalResponse = normalizeHttpResponse(result.response);
957
- finalError = result.error;
958
- finalOwner = responseOwnerFor(finalResponse, result.owner);
959
- responseValidation = "not-applicable";
960
966
  } else {
961
- throw error;
967
+ result = await resolveErrorResult(
968
+ error,
969
+ result.ctx,
970
+ pathValue,
971
+ queryValue,
972
+ headersValue,
973
+ bodyValue,
974
+ { owner: "framework" },
975
+ );
962
976
  }
977
+ finalResponse = normalizeHttpResponse(result.response);
978
+ finalError = result.error;
979
+ finalOwner = responseOwnerFor(finalResponse, result.owner);
980
+ result = await applyTransformHooks(result, true);
981
+ finalResponse = normalizeHttpResponse(result.response);
982
+ finalError = result.error;
983
+ finalOwner = responseOwnerFor(finalResponse, result.owner);
984
+ responseValidation = "not-applicable";
963
985
  }
964
986
  }
965
987
 
@@ -1,10 +1,11 @@
1
1
  import {
2
+ decodeQueryTransport,
2
3
  getContractHeaderSchemas,
3
4
  type HttpContractConfig,
4
5
  methodSupportsRequestBody,
6
+ QueryTransportError,
5
7
  type StandardSchema,
6
8
  } from "../contracts/index.js";
7
- import { decodeQueryValue } from "../query-codec.js";
8
9
  import type { HttpRequestLike, HttpResponseLike } from "./http.js";
9
10
  import {
10
11
  parseStandardSchema,
@@ -40,6 +41,17 @@ type RequestValidationLocation = "query" | "path" | "headers" | "body";
40
41
 
41
42
  const DEFAULT_REQUEST_BODY_MAX_BYTES = 1024 * 1024;
42
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
+
43
55
  class RequestBodyTooLargeError extends Error {
44
56
  readonly maxBytes: number;
45
57
  readonly actualBytes?: number;
@@ -82,7 +94,10 @@ function requestValidationDetails(
82
94
  ...additionalDetails,
83
95
  };
84
96
 
85
- if (error instanceof SchemaValidationError) {
97
+ if (
98
+ error instanceof SchemaValidationError ||
99
+ error instanceof QueryTransportError
100
+ ) {
86
101
  return {
87
102
  ...details,
88
103
  issues: error.issues,
@@ -204,9 +219,16 @@ async function readLimitedRequestText(
204
219
  req: HttpRequestLike,
205
220
  maxBytes: number,
206
221
  ): Promise<string> {
207
- assertContentLengthWithinLimit(req.headers, maxBytes);
208
-
209
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
+
210
232
  if (!body) {
211
233
  const text = await req.text();
212
234
  const actualBytes = new TextEncoder().encode(text).byteLength;
@@ -227,7 +249,9 @@ async function readLimitedRequestText(
227
249
  if (result.done) break;
228
250
  received += result.value.byteLength;
229
251
  if (received > maxBytes) {
230
- throw new RequestBodyTooLargeError(maxBytes, received);
252
+ const error = new RequestBodyTooLargeError(maxBytes, received);
253
+ cancelRequestBody(reader, error);
254
+ throw error;
231
255
  }
232
256
  text += decoder.decode(result.value, { stream: true });
233
257
  }
@@ -282,17 +306,26 @@ export async function prepareRequestInputs(args: {
282
306
  maxRequestBodyBytes,
283
307
  rawRoute,
284
308
  } = args;
285
- const rawQuery: Record<string, unknown> = {};
286
- for (const key of new Set(url.searchParams.keys())) {
287
- const values = url.searchParams.getAll(key);
288
- const decoded = values.map(decodeQueryValue);
289
- rawQuery[key] = decoded.length === 1 ? decoded[0] : decoded;
290
- }
309
+ const rawQuery = Object.fromEntries(
310
+ [...new Set(url.searchParams.keys())].map((key) => {
311
+ const values = url.searchParams.getAll(key);
312
+ return [key, values.length === 1 ? values[0] : values];
313
+ }),
314
+ );
291
315
 
292
316
  let query: unknown = rawQuery;
293
317
  if (contract.query) {
318
+ if (!contract.queryTransport) {
319
+ throw new Error(
320
+ `Contract "${contract.name}" declares a query schema without a query transport.`,
321
+ );
322
+ }
294
323
  try {
295
- query = await parseStandardSchema(contract.query, query);
324
+ const decoded = decodeQueryTransport(
325
+ contract.queryTransport,
326
+ url.searchParams,
327
+ );
328
+ query = await parseStandardSchema(contract.query, decoded);
296
329
  } catch (error) {
297
330
  return {
298
331
  ok: false,
@@ -9,7 +9,11 @@ import {
9
9
  isErrorResponseBody,
10
10
  } from "../errors/index.js";
11
11
  import { getRequestIdFromContext } from "./hooks/utils.js";
12
- import type { HttpResponse, HttpResponseLike } from "./http.js";
12
+ import type {
13
+ HttpResponse,
14
+ HttpResponseHeaders,
15
+ HttpResponseLike,
16
+ } from "./http.js";
13
17
  import type { ResponseFinalizerResponseOwner } from "./internal-hooks.js";
14
18
  import {
15
19
  parseStandardSchema,
@@ -76,7 +80,7 @@ export function withFrameworkErrorOwnerHeader(
76
80
  }
77
81
 
78
82
  function setRecordHeader(
79
- headers: Record<string, string>,
83
+ headers: HttpResponseHeaders,
80
84
  name: string,
81
85
  value: string,
82
86
  ): void {
@@ -89,6 +93,34 @@ function setRecordHeader(
89
93
  headers[name] = value;
90
94
  }
91
95
 
96
+ function headerValues(value: string | readonly string[]): readonly string[] {
97
+ return typeof value === "string" ? [value] : value;
98
+ }
99
+
100
+ function sameHeaderValue(
101
+ left: string | readonly string[] | undefined,
102
+ right: string | readonly string[],
103
+ ): boolean {
104
+ if (left === undefined) return false;
105
+ const leftValues = headerValues(left);
106
+ const rightValues = headerValues(right);
107
+ return (
108
+ leftValues.length === rightValues.length &&
109
+ leftValues.every((value, index) => value === rightValues[index])
110
+ );
111
+ }
112
+
113
+ function appendHeaderValues(
114
+ headers: Headers,
115
+ name: string,
116
+ value: string | readonly string[],
117
+ ): void {
118
+ headers.delete(name);
119
+ for (const item of headerValues(value)) {
120
+ headers.append(name, item);
121
+ }
122
+ }
123
+
92
124
  /** Apply contract-owned deprecation headers to any response representation. */
93
125
  export function withContractLifecycleHeaders(
94
126
  res: HttpResponse,
@@ -113,17 +145,18 @@ export function withContractLifecycleHeaders(
113
145
  });
114
146
  }
115
147
 
116
- const headers = { ...(res.headers ?? {}) };
148
+ const headers: HttpResponseHeaders = { ...(res.headers ?? {}) };
117
149
  for (const [name, value] of Object.entries(lifecycleHeaders)) {
118
150
  if (name.toLowerCase() === "link") {
119
151
  const existingName = Object.keys(headers).find(
120
152
  (key) => key.toLowerCase() === "link",
121
153
  );
122
154
  const existing = existingName ? headers[existingName] : undefined;
155
+ const existingValue = existing ? headerValues(existing).join(", ") : "";
123
156
  setRecordHeader(
124
157
  headers,
125
158
  name,
126
- existing ? `${existing}, ${value}` : value,
159
+ existingValue ? `${existingValue}, ${value}` : value,
127
160
  );
128
161
  } else {
129
162
  setRecordHeader(headers, name, value);
@@ -140,11 +173,18 @@ export function responseOwnerFor(
140
173
  return owner ?? "route";
141
174
  }
142
175
 
143
- function headersToRecord(headers: Headers): Record<string, string> {
144
- const record: Record<string, string> = {};
176
+ function headersToRecord(headers: Headers): HttpResponseHeaders {
177
+ const record: HttpResponseHeaders = {};
145
178
  headers.forEach((value, key) => {
146
179
  record[key] = value;
147
180
  });
181
+ const setCookies =
182
+ (headers as Headers & { getSetCookie?: () => string[] }).getSetCookie?.call(
183
+ headers,
184
+ ) ?? [];
185
+ if (setCookies.length > 0) {
186
+ record["set-cookie"] = setCookies;
187
+ }
148
188
  return record;
149
189
  }
150
190
 
@@ -169,10 +209,10 @@ export function responseForHooks(res: HttpResponse): HttpResponseLike {
169
209
  */
170
210
  export function mergeNativeResponseHeaders(
171
211
  nativeResponse: Response,
172
- originalHeaders: Record<string, string>,
173
- finalHeaders: Record<string, string>,
212
+ originalHeaders: HttpResponseHeaders,
213
+ finalHeaders: HttpResponseHeaders,
174
214
  ): Response {
175
- const originalByLowerKey = new Map<string, string>();
215
+ const originalByLowerKey = new Map<string, string | readonly string[]>();
176
216
  for (const [key, value] of Object.entries(originalHeaders)) {
177
217
  originalByLowerKey.set(key.toLowerCase(), value);
178
218
  }
@@ -181,13 +221,9 @@ export function mergeNativeResponseHeaders(
181
221
  const merged = new Headers(nativeResponse.headers);
182
222
  for (const [key, value] of Object.entries(finalHeaders)) {
183
223
  const lowerKey = key.toLowerCase();
184
- if (originalByLowerKey.get(lowerKey) === value) continue;
224
+ if (sameHeaderValue(originalByLowerKey.get(lowerKey), value)) continue;
185
225
  changed = true;
186
- if (lowerKey === "set-cookie") {
187
- merged.append(lowerKey, value);
188
- } else {
189
- merged.set(key, value);
190
- }
226
+ appendHeaderValues(merged, key, value);
191
227
  }
192
228
 
193
229
  if (!changed) {
@@ -15,12 +15,35 @@ export class PathDecodeError extends Error {
15
15
  }
16
16
  }
17
17
 
18
+ function encodeStaticSegment(value: string): string {
19
+ return encodeURI(value).replace(
20
+ /[?#]/g,
21
+ (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
22
+ );
23
+ }
24
+
25
+ function matchPercentEncodingCase(value: string): string {
26
+ return value.replace(/%([0-9A-F]{2})/g, (_match, encoded: string) => {
27
+ const digits = [...encoded]
28
+ .map((digit) =>
29
+ /[A-F]/.test(digit) ? `[${digit}${digit.toLowerCase()}]` : digit,
30
+ )
31
+ .join("");
32
+ return `%${digits}`;
33
+ });
34
+ }
35
+
18
36
  export function compilePath(path: string): CompiledPath {
19
37
  const parsed = parsePathTemplate(path);
20
38
  const regexParts = parsed.segments.map((segment) =>
21
39
  segment.kind === "dynamic"
22
40
  ? "([^/]+)"
23
- : segment.value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
41
+ : matchPercentEncodingCase(
42
+ encodeStaticSegment(segment.value).replace(
43
+ /[.*+?^${}()|[\]\\]/g,
44
+ "\\$&",
45
+ ),
46
+ ),
24
47
  );
25
48
  const pattern = new RegExp(`^/${regexParts.join("/")}$`);
26
49
  return { ...parsed, pattern };