@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.
- package/CHANGELOG.md +22 -0
- package/README.md +160 -6
- 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/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/events.d.ts +7 -5
- package/dist/ports/events.d.ts.map +1 -1
- package/dist/ports/index.d.ts +6 -1
- package/dist/ports/index.d.ts.map +1 -1
- package/dist/ports/index.js +1 -0
- 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/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 +9 -0
- 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 +20 -2
- 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 +41 -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/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 +1 -1
- package/skills/app-architecture/SKILL.md +23 -3
- package/src/events/index.ts +263 -38
- package/src/idempotency/index.ts +65 -17
- package/src/mail/index.ts +7 -3
- package/src/ports/best-effort-work.ts +21 -0
- package/src/ports/events.ts +9 -4
- package/src/ports/index.ts +9 -0
- 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/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 +14 -0
- package/src/server/request-preparation.ts +23 -3
- 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 +46 -22
- package/src/server/trusted-proxy-internal.ts +20 -0
- package/src/server/trusted-proxy.ts +6 -7
- package/src/testing/index.ts +30 -0
- package/dist/query-codec.d.ts +0 -27
- package/dist/query-codec.d.ts.map +0 -1
- package/dist/query-codec.js +0 -245
- package/dist/query-codec.js.map +0 -1
package/src/idempotency/index.ts
CHANGED
|
@@ -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
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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
|
-
|
|
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
|
-
|
|
905
|
-
|
|
906
|
-
|
|
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,
|
|
950
|
-
*
|
|
951
|
-
*
|
|
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 ??
|
|
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:
|
|
413
|
+
summary: "Mail sent",
|
|
410
414
|
details: {
|
|
411
|
-
|
|
412
|
-
|
|
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
|
+
}
|
package/src/ports/events.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
):
|
|
96
|
+
): EventSubscription;
|
|
92
97
|
}
|
|
93
98
|
|
|
94
99
|
/**
|
package/src/ports/index.ts
CHANGED
|
@@ -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,
|
package/src/ports/testing.ts
CHANGED
|
@@ -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
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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
|
|
226
|
-
* entrypoints such as job
|
|
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>;
|
package/src/server/hooks/cors.ts
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
* CORS hook utilities for @beignet/core/server
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type {
|
|
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:
|
|
62
|
+
function appendVaryOrigin(headers: HttpResponseHeaders): void {
|
|
59
63
|
const varyKey =
|
|
60
64
|
Object.keys(headers).find((key) => key.toLowerCase() === "vary") ?? "Vary";
|
|
61
|
-
const
|
|
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:
|
|
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:
|
|
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 {
|
|
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:
|
|
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 {
|
|
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,
|
|
@@ -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
|
-
|
|
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
|
}
|