@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.
- package/CHANGELOG.md +46 -0
- package/README.md +224 -20
- package/dist/client/client.d.ts +0 -2
- package/dist/client/client.d.ts.map +1 -1
- package/dist/client/client.js +28 -25
- package/dist/client/client.js.map +1 -1
- package/dist/contracts/contract-builder.d.ts +7 -2
- package/dist/contracts/contract-builder.d.ts.map +1 -1
- package/dist/contracts/contract-builder.js +20 -2
- package/dist/contracts/contract-builder.js.map +1 -1
- package/dist/contracts/contract-group.d.ts.map +1 -1
- package/dist/contracts/contract-group.js +1 -0
- package/dist/contracts/contract-group.js.map +1 -1
- package/dist/contracts/contract-like.d.ts +2 -0
- package/dist/contracts/contract-like.d.ts.map +1 -1
- package/dist/contracts/contract-like.js +27 -1
- package/dist/contracts/contract-like.js.map +1 -1
- package/dist/contracts/index.d.ts +4 -0
- package/dist/contracts/index.d.ts.map +1 -1
- package/dist/contracts/index.js +4 -0
- package/dist/contracts/index.js.map +1 -1
- package/dist/contracts/query-transport.d.ts +126 -0
- package/dist/contracts/query-transport.d.ts.map +1 -0
- package/dist/contracts/query-transport.js +406 -0
- package/dist/contracts/query-transport.js.map +1 -0
- package/dist/contracts/schema-shape.d.ts +11 -0
- package/dist/contracts/schema-shape.d.ts.map +1 -1
- package/dist/contracts/schema-shape.js +13 -0
- package/dist/contracts/schema-shape.js.map +1 -1
- package/dist/contracts/types.d.ts +5 -0
- package/dist/contracts/types.d.ts.map +1 -1
- package/dist/contracts/types.js.map +1 -1
- package/dist/events/index.d.ts +54 -5
- package/dist/events/index.d.ts.map +1 -1
- package/dist/events/index.js +183 -32
- package/dist/events/index.js.map +1 -1
- package/dist/idempotency/index.d.ts +7 -3
- package/dist/idempotency/index.d.ts.map +1 -1
- package/dist/idempotency/index.js +45 -12
- package/dist/idempotency/index.js.map +1 -1
- package/dist/mail/index.d.ts.map +1 -1
- package/dist/mail/index.js +6 -3
- package/dist/mail/index.js.map +1 -1
- package/dist/openapi/index.d.ts +8 -0
- package/dist/openapi/index.d.ts.map +1 -1
- package/dist/openapi/index.js +79 -5
- package/dist/openapi/index.js.map +1 -1
- package/dist/outbox/index.d.ts +8 -5
- package/dist/outbox/index.d.ts.map +1 -1
- package/dist/outbox/index.js +17 -3
- package/dist/outbox/index.js.map +1 -1
- package/dist/ports/best-effort-work.d.ts +21 -0
- package/dist/ports/best-effort-work.d.ts.map +1 -0
- package/dist/ports/best-effort-work.js +2 -0
- package/dist/ports/best-effort-work.js.map +1 -0
- package/dist/ports/cache.d.ts +9 -1
- package/dist/ports/cache.d.ts.map +1 -1
- package/dist/ports/cache.js +20 -5
- package/dist/ports/cache.js.map +1 -1
- package/dist/ports/events.d.ts +7 -5
- package/dist/ports/events.d.ts.map +1 -1
- package/dist/ports/index.d.ts +7 -2
- package/dist/ports/index.d.ts.map +1 -1
- package/dist/ports/index.js +2 -1
- package/dist/ports/index.js.map +1 -1
- package/dist/ports/testing.d.ts +15 -0
- package/dist/ports/testing.d.ts.map +1 -1
- package/dist/ports/testing.js +38 -0
- package/dist/ports/testing.js.map +1 -1
- package/dist/providers/provider.d.ts +8 -5
- package/dist/providers/provider.d.ts.map +1 -1
- package/dist/providers/provider.js.map +1 -1
- package/dist/server/hooks/cors.d.ts +2 -2
- package/dist/server/hooks/cors.d.ts.map +1 -1
- package/dist/server/hooks/cors.js +2 -1
- package/dist/server/hooks/cors.js.map +1 -1
- package/dist/server/hooks/logging.d.ts +2 -2
- package/dist/server/hooks/logging.d.ts.map +1 -1
- package/dist/server/hooks/logging.js.map +1 -1
- package/dist/server/hooks/rate-limit.d.ts +16 -8
- package/dist/server/hooks/rate-limit.d.ts.map +1 -1
- package/dist/server/hooks/rate-limit.js +31 -17
- package/dist/server/hooks/rate-limit.js.map +1 -1
- package/dist/server/hooks/security.d.ts +2 -2
- package/dist/server/hooks/security.d.ts.map +1 -1
- package/dist/server/hooks/security.js.map +1 -1
- package/dist/server/http.d.ts +21 -2
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +4 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/instrumentation.d.ts.map +1 -1
- package/dist/server/instrumentation.js +5 -3
- package/dist/server/instrumentation.js.map +1 -1
- package/dist/server/request-executor.d.ts.map +1 -1
- package/dist/server/request-executor.js +18 -9
- package/dist/server/request-executor.js.map +1 -1
- package/dist/server/request-preparation.d.ts.map +1 -1
- package/dist/server/request-preparation.js +31 -11
- package/dist/server/request-preparation.js.map +1 -1
- package/dist/server/response-finalization.d.ts +2 -2
- package/dist/server/response-finalization.d.ts.map +1 -1
- package/dist/server/response-finalization.js +25 -8
- package/dist/server/response-finalization.js.map +1 -1
- package/dist/server/route-matching.d.ts.map +1 -1
- package/dist/server/route-matching.js +12 -1
- package/dist/server/route-matching.js.map +1 -1
- package/dist/server/server-sent-events.d.ts +94 -0
- package/dist/server/server-sent-events.d.ts.map +1 -0
- package/dist/server/server-sent-events.js +275 -0
- package/dist/server/server-sent-events.js.map +1 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +43 -22
- package/dist/server/server.js.map +1 -1
- package/dist/server/trusted-proxy-internal.d.ts +4 -0
- package/dist/server/trusted-proxy-internal.d.ts.map +1 -1
- package/dist/server/trusted-proxy-internal.js +20 -0
- package/dist/server/trusted-proxy-internal.js.map +1 -1
- package/dist/server/trusted-proxy.d.ts.map +1 -1
- package/dist/server/trusted-proxy.js +3 -8
- package/dist/server/trusted-proxy.js.map +1 -1
- package/dist/server/use-case-route.d.ts +8 -5
- package/dist/server/use-case-route.d.ts.map +1 -1
- package/dist/server/use-case-route.js +44 -17
- package/dist/server/use-case-route.js.map +1 -1
- package/dist/testing/index.d.ts +17 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +6 -1
- package/dist/testing/index.js.map +1 -1
- package/package.json +3 -3
- package/skills/app-architecture/SKILL.md +50 -4
- package/src/client/client.ts +29 -28
- package/src/contracts/contract-builder.ts +32 -2
- package/src/contracts/contract-group.ts +1 -0
- package/src/contracts/contract-like.ts +40 -1
- package/src/contracts/index.ts +23 -0
- package/src/contracts/query-transport.ts +697 -0
- package/src/contracts/schema-shape.ts +24 -0
- package/src/contracts/types.ts +5 -0
- package/src/events/index.ts +263 -38
- package/src/idempotency/index.ts +65 -17
- package/src/mail/index.ts +7 -3
- package/src/openapi/index.ts +126 -2
- package/src/outbox/index.ts +26 -5
- package/src/ports/best-effort-work.ts +21 -0
- package/src/ports/cache.ts +29 -7
- package/src/ports/events.ts +9 -4
- package/src/ports/index.ts +10 -1
- package/src/ports/testing.ts +45 -0
- package/src/providers/provider.ts +8 -5
- package/src/server/hooks/cors.ts +11 -5
- package/src/server/hooks/logging.ts +6 -2
- package/src/server/hooks/rate-limit.ts +50 -24
- package/src/server/hooks/security.ts +8 -4
- package/src/server/http.ts +23 -2
- package/src/server/index.ts +4 -0
- package/src/server/instrumentation.ts +12 -4
- package/src/server/request-executor.ts +31 -9
- package/src/server/request-preparation.ts +45 -12
- package/src/server/response-finalization.ts +51 -15
- package/src/server/route-matching.ts +24 -1
- package/src/server/server-sent-events.ts +415 -0
- package/src/server/server.ts +48 -22
- package/src/server/trusted-proxy-internal.ts +20 -0
- package/src/server/trusted-proxy.ts +6 -7
- package/src/server/use-case-route.ts +62 -23
- package/src/testing/index.ts +30 -0
- package/dist/query-codec.d.ts +0 -3
- package/dist/query-codec.d.ts.map +0 -1
- package/dist/query-codec.js +0 -110
- package/dist/query-codec.js.map +0 -1
- 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
|
|
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
|
-
*
|
|
42
|
-
* that declare `ip` scopes without a trusted
|
|
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
|
|
135
|
+
`or "none" to explicitly accept one shared unknown-client bucket per contract.`,
|
|
128
136
|
);
|
|
129
137
|
}
|
|
130
138
|
|
|
131
|
-
function
|
|
132
|
-
|
|
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"
|
|
150
|
-
|
|
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.
|
|
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
|
|
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
|
|
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(
|
|
313
|
-
|
|
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(
|
|
339
|
-
|
|
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 {
|
|
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:
|
|
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:
|
|
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:
|
|
224
|
+
headers: HttpResponseHeaders,
|
|
221
225
|
options: SecurityHeadersOptions = {},
|
|
222
226
|
): void {
|
|
223
227
|
setHeaderIfMissing(
|
package/src/server/http.ts
CHANGED
|
@@ -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?:
|
|
97
|
+
headers?: HttpResponseHeaders;
|
|
77
98
|
/**
|
|
78
99
|
* JSON-serializable body or an adapter-specific body value.
|
|
79
100
|
*/
|
package/src/server/index.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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):
|
|
144
|
-
const record:
|
|
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:
|
|
173
|
-
finalHeaders:
|
|
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)
|
|
224
|
+
if (sameHeaderValue(originalByLowerKey.get(lowerKey), value)) continue;
|
|
185
225
|
changed = true;
|
|
186
|
-
|
|
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
|
-
:
|
|
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 };
|