@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
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
const DEFAULT_HEARTBEAT_MS = 25_000;
|
|
2
|
+
const DEFAULT_MAX_BUFFERED_BYTES = 1_048_576;
|
|
3
|
+
const MAX_BUFFERED_BYTES = 2_147_483_647;
|
|
4
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One JSON-encoded Server-Sent Event.
|
|
8
|
+
*/
|
|
9
|
+
export interface ServerSentEventMessage<TData = unknown> {
|
|
10
|
+
/** JSON-serializable event payload. */
|
|
11
|
+
data: TData;
|
|
12
|
+
/** Optional event type consumed by `EventSource.addEventListener(...)`. */
|
|
13
|
+
event?: string;
|
|
14
|
+
/** Optional event identifier used by browsers for `Last-Event-ID`. */
|
|
15
|
+
id?: string;
|
|
16
|
+
/** Optional browser reconnection delay in milliseconds. */
|
|
17
|
+
retry?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Controls exposed while an SSE response is active.
|
|
22
|
+
*/
|
|
23
|
+
export interface ServerSentEventStream {
|
|
24
|
+
/**
|
|
25
|
+
* Aborts whenever this stream closes, including response cancellation.
|
|
26
|
+
* Async setup should pass it to cancellable subscription APIs.
|
|
27
|
+
*/
|
|
28
|
+
readonly signal: AbortSignal;
|
|
29
|
+
/**
|
|
30
|
+
* Send a JSON-encoded event.
|
|
31
|
+
*
|
|
32
|
+
* Returns `false` after the stream closes, when encoding fails, or when the
|
|
33
|
+
* unread byte limit would be exceeded.
|
|
34
|
+
*/
|
|
35
|
+
send<TData>(message: ServerSentEventMessage<TData>): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Send an SSE comment. Comments are useful as connection heartbeats.
|
|
38
|
+
*
|
|
39
|
+
* Returns `false` when the stream is no longer writable or its unread byte
|
|
40
|
+
* limit would be exceeded.
|
|
41
|
+
*/
|
|
42
|
+
comment(value?: string): boolean;
|
|
43
|
+
/** Close the stream and run its cleanup exactly once. */
|
|
44
|
+
close(): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Closeable subscription returned by an SSE stream's `start` callback. */
|
|
48
|
+
export interface ServerSentEventSubscription {
|
|
49
|
+
/** Close the associated subscription. */
|
|
50
|
+
close(): Promise<void> | void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Cleanup returned by an SSE stream's `start` callback. */
|
|
54
|
+
export type ServerSentEventCleanup =
|
|
55
|
+
| (() => Promise<void> | void)
|
|
56
|
+
| ServerSentEventSubscription;
|
|
57
|
+
|
|
58
|
+
/** Options for `createServerSentEventResponse(...)`. */
|
|
59
|
+
export interface ServerSentEventResponseOptions {
|
|
60
|
+
/**
|
|
61
|
+
* Begin producing events. Return a cleanup callback or closeable
|
|
62
|
+
* subscription for resources associated with this connection. Pending
|
|
63
|
+
* asynchronous setup does not block response cancellation; honor the stream
|
|
64
|
+
* signal and return cleanup when setup settles.
|
|
65
|
+
*/
|
|
66
|
+
start(
|
|
67
|
+
stream: ServerSentEventStream,
|
|
68
|
+
):
|
|
69
|
+
| Promise<ServerSentEventCleanup | undefined>
|
|
70
|
+
| ServerSentEventCleanup
|
|
71
|
+
| undefined;
|
|
72
|
+
/**
|
|
73
|
+
* Abort the stream with the surrounding request or application lifecycle.
|
|
74
|
+
*/
|
|
75
|
+
signal?: AbortSignal;
|
|
76
|
+
/**
|
|
77
|
+
* Interval for SSE heartbeat comments. Defaults to 25 seconds. Set to
|
|
78
|
+
* `false` to disable heartbeats.
|
|
79
|
+
*/
|
|
80
|
+
heartbeatMs?: number | false;
|
|
81
|
+
/**
|
|
82
|
+
* Close the connection after this duration so clients can reconnect and
|
|
83
|
+
* reconcile. Disabled by default.
|
|
84
|
+
*/
|
|
85
|
+
maxLifetimeMs?: number | false;
|
|
86
|
+
/**
|
|
87
|
+
* Maximum bytes of unread event data held by the response stream. Defaults
|
|
88
|
+
* to 1 MiB. The connection closes when one frame or the accumulated queue
|
|
89
|
+
* would exceed this limit.
|
|
90
|
+
*/
|
|
91
|
+
maxBufferedBytes?: number;
|
|
92
|
+
/** Additional response headers such as CORS or `Vary`. */
|
|
93
|
+
headers?: HeadersInit;
|
|
94
|
+
/**
|
|
95
|
+
* Observe producer, serialization, buffer, stream, or cleanup failures.
|
|
96
|
+
* Expected `AbortError` rejections caused by stream closure are ignored.
|
|
97
|
+
*/
|
|
98
|
+
onError?(error: unknown): Promise<void> | void;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function assertTimerOption(
|
|
102
|
+
name: "heartbeatMs" | "maxLifetimeMs",
|
|
103
|
+
value: number | false | undefined,
|
|
104
|
+
): void {
|
|
105
|
+
if (
|
|
106
|
+
value === false ||
|
|
107
|
+
value === undefined ||
|
|
108
|
+
(Number.isInteger(value) && value >= 1 && value <= MAX_TIMER_MS)
|
|
109
|
+
) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
throw new RangeError(
|
|
114
|
+
`${name} must be false or an integer between 1 and ${MAX_TIMER_MS} milliseconds.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function assertMaxBufferedBytes(value: number): void {
|
|
119
|
+
if (Number.isInteger(value) && value >= 1 && value <= MAX_BUFFERED_BYTES) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
throw new RangeError(
|
|
124
|
+
`maxBufferedBytes must be an integer between 1 and ${MAX_BUFFERED_BYTES} bytes.`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function assertSingleLine(name: "event" | "id", value: string): void {
|
|
129
|
+
if (value.includes("\r") || value.includes("\n")) {
|
|
130
|
+
throw new TypeError(`SSE ${name} must not contain line breaks.`);
|
|
131
|
+
}
|
|
132
|
+
if (name === "id" && value.includes("\0")) {
|
|
133
|
+
throw new TypeError("SSE id must not contain null characters.");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isPromiseLike<T>(value: T | PromiseLike<T>): value is PromiseLike<T> {
|
|
138
|
+
return (
|
|
139
|
+
value !== null &&
|
|
140
|
+
(typeof value === "object" || typeof value === "function") &&
|
|
141
|
+
"then" in value &&
|
|
142
|
+
typeof value.then === "function"
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isAbortError(error: unknown): boolean {
|
|
147
|
+
try {
|
|
148
|
+
return (
|
|
149
|
+
typeof error === "object" &&
|
|
150
|
+
error !== null &&
|
|
151
|
+
"name" in error &&
|
|
152
|
+
error.name === "AbortError"
|
|
153
|
+
);
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function encodeMessage<TData>(message: ServerSentEventMessage<TData>): string {
|
|
160
|
+
if (message.event !== undefined) {
|
|
161
|
+
assertSingleLine("event", message.event);
|
|
162
|
+
}
|
|
163
|
+
if (message.id !== undefined) {
|
|
164
|
+
assertSingleLine("id", message.id);
|
|
165
|
+
}
|
|
166
|
+
if (
|
|
167
|
+
message.retry !== undefined &&
|
|
168
|
+
(!Number.isInteger(message.retry) ||
|
|
169
|
+
message.retry < 0 ||
|
|
170
|
+
message.retry > MAX_TIMER_MS)
|
|
171
|
+
) {
|
|
172
|
+
throw new RangeError(
|
|
173
|
+
`SSE retry must be an integer between 0 and ${MAX_TIMER_MS} milliseconds.`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const data = JSON.stringify(message.data);
|
|
178
|
+
if (data === undefined) {
|
|
179
|
+
throw new TypeError("SSE data must be JSON-serializable.");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const fields: string[] = [];
|
|
183
|
+
if (message.event !== undefined) fields.push(`event: ${message.event}`);
|
|
184
|
+
if (message.id !== undefined) fields.push(`id: ${message.id}`);
|
|
185
|
+
if (message.retry !== undefined) fields.push(`retry: ${message.retry}`);
|
|
186
|
+
fields.push(`data: ${data}`);
|
|
187
|
+
return `${fields.join("\n")}\n\n`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function encodeComment(value: string): string {
|
|
191
|
+
return `${value
|
|
192
|
+
.split(/\r\n|\r|\n/)
|
|
193
|
+
.map((line) => `: ${line}`)
|
|
194
|
+
.join("\n")}\n\n`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function createResponseHeaders(init: HeadersInit | undefined): Headers {
|
|
198
|
+
const headers = new Headers(init);
|
|
199
|
+
headers.set("Content-Type", "text/event-stream; charset=utf-8");
|
|
200
|
+
headers.set("Cache-Control", "no-store, no-transform");
|
|
201
|
+
headers.set("X-Accel-Buffering", "no");
|
|
202
|
+
headers.delete("Connection");
|
|
203
|
+
headers.delete("Content-Length");
|
|
204
|
+
headers.delete("Transfer-Encoding");
|
|
205
|
+
return headers;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Create a portable Fetch `Response` that safely manages a Server-Sent Events
|
|
210
|
+
* stream.
|
|
211
|
+
*
|
|
212
|
+
* The helper owns SSE framing, JSON encoding, heartbeats, abort handling,
|
|
213
|
+
* bounded unread buffering, maximum lifetime, and cleanup. Authentication,
|
|
214
|
+
* replay, authorization, connection limits, and application reconciliation
|
|
215
|
+
* remain the caller's responsibility.
|
|
216
|
+
*/
|
|
217
|
+
export function createServerSentEventResponse(
|
|
218
|
+
options: ServerSentEventResponseOptions,
|
|
219
|
+
): Response {
|
|
220
|
+
const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
|
|
221
|
+
const maxBufferedBytes =
|
|
222
|
+
options.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
|
|
223
|
+
assertTimerOption("heartbeatMs", heartbeatMs);
|
|
224
|
+
assertTimerOption("maxLifetimeMs", options.maxLifetimeMs);
|
|
225
|
+
assertMaxBufferedBytes(maxBufferedBytes);
|
|
226
|
+
const headers = createResponseHeaders(options.headers);
|
|
227
|
+
|
|
228
|
+
const encoder = new TextEncoder();
|
|
229
|
+
const lifecycleController = new AbortController();
|
|
230
|
+
let cleanup: ServerSentEventCleanup | undefined;
|
|
231
|
+
let cleanupPromise: Promise<void> | undefined;
|
|
232
|
+
let closed = false;
|
|
233
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
|
234
|
+
let lifetimeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
235
|
+
let abortListener: (() => void) | undefined;
|
|
236
|
+
let closeStream: (() => void) | undefined;
|
|
237
|
+
|
|
238
|
+
const reportError = (error: unknown): void => {
|
|
239
|
+
if (!options.onError) return;
|
|
240
|
+
void Promise.resolve()
|
|
241
|
+
.then(() => options.onError?.(error))
|
|
242
|
+
.catch(() => undefined);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const runCleanup = (): Promise<void> => {
|
|
246
|
+
if (cleanupPromise) return cleanupPromise;
|
|
247
|
+
if (!cleanup) return Promise.resolve();
|
|
248
|
+
const activeCleanup = cleanup;
|
|
249
|
+
cleanupPromise = Promise.resolve()
|
|
250
|
+
.then(() =>
|
|
251
|
+
typeof activeCleanup === "function"
|
|
252
|
+
? activeCleanup()
|
|
253
|
+
: activeCleanup.close(),
|
|
254
|
+
)
|
|
255
|
+
.catch(reportError);
|
|
256
|
+
return cleanupPromise;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const stream = new ReadableStream<Uint8Array>(
|
|
260
|
+
{
|
|
261
|
+
start(controller) {
|
|
262
|
+
const clearLifecycle = () => {
|
|
263
|
+
if (heartbeatTimer !== undefined) clearInterval(heartbeatTimer);
|
|
264
|
+
if (lifetimeTimer !== undefined) clearTimeout(lifetimeTimer);
|
|
265
|
+
if (abortListener) {
|
|
266
|
+
options.signal?.removeEventListener("abort", abortListener);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const close = () => {
|
|
271
|
+
if (closed) return;
|
|
272
|
+
closed = true;
|
|
273
|
+
clearLifecycle();
|
|
274
|
+
lifecycleController.abort();
|
|
275
|
+
void runCleanup();
|
|
276
|
+
try {
|
|
277
|
+
controller.close();
|
|
278
|
+
} catch {
|
|
279
|
+
// Cancellation may already have detached the stream controller.
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
closeStream = close;
|
|
283
|
+
|
|
284
|
+
const enqueue = (value: string): boolean => {
|
|
285
|
+
if (closed) return false;
|
|
286
|
+
try {
|
|
287
|
+
const chunk = encoder.encode(value);
|
|
288
|
+
const availableBytes = controller.desiredSize;
|
|
289
|
+
if (availableBytes === null || chunk.byteLength > availableBytes) {
|
|
290
|
+
reportError(
|
|
291
|
+
new RangeError(
|
|
292
|
+
`SSE buffer limit of ${maxBufferedBytes} bytes exceeded.`,
|
|
293
|
+
),
|
|
294
|
+
);
|
|
295
|
+
close();
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
controller.enqueue(chunk);
|
|
299
|
+
return true;
|
|
300
|
+
} catch (error) {
|
|
301
|
+
reportError(error);
|
|
302
|
+
close();
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const controls: ServerSentEventStream = {
|
|
308
|
+
signal: lifecycleController.signal,
|
|
309
|
+
send(message) {
|
|
310
|
+
if (closed) return false;
|
|
311
|
+
try {
|
|
312
|
+
return enqueue(encodeMessage(message));
|
|
313
|
+
} catch (error) {
|
|
314
|
+
reportError(error);
|
|
315
|
+
close();
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
comment(value = "") {
|
|
320
|
+
if (closed) return false;
|
|
321
|
+
try {
|
|
322
|
+
return enqueue(encodeComment(value));
|
|
323
|
+
} catch (error) {
|
|
324
|
+
reportError(error);
|
|
325
|
+
close();
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
close,
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
if (options.signal?.aborted) {
|
|
333
|
+
close();
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
abortListener = close;
|
|
338
|
+
options.signal?.addEventListener("abort", abortListener, {
|
|
339
|
+
once: true,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
if (heartbeatMs !== false) {
|
|
343
|
+
heartbeatTimer = setInterval(() => {
|
|
344
|
+
controls.comment("heartbeat");
|
|
345
|
+
}, heartbeatMs);
|
|
346
|
+
}
|
|
347
|
+
if (
|
|
348
|
+
options.maxLifetimeMs !== undefined &&
|
|
349
|
+
options.maxLifetimeMs !== false
|
|
350
|
+
) {
|
|
351
|
+
lifetimeTimer = setTimeout(close, options.maxLifetimeMs);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const registerCleanup = (
|
|
355
|
+
resolvedCleanup: ServerSentEventCleanup | undefined,
|
|
356
|
+
): void => {
|
|
357
|
+
try {
|
|
358
|
+
if (
|
|
359
|
+
typeof resolvedCleanup === "function" ||
|
|
360
|
+
(typeof resolvedCleanup === "object" &&
|
|
361
|
+
resolvedCleanup !== null &&
|
|
362
|
+
"close" in resolvedCleanup &&
|
|
363
|
+
typeof resolvedCleanup.close === "function")
|
|
364
|
+
) {
|
|
365
|
+
cleanup = resolvedCleanup;
|
|
366
|
+
if (closed) void runCleanup();
|
|
367
|
+
} else if (resolvedCleanup !== undefined) {
|
|
368
|
+
reportError(
|
|
369
|
+
new TypeError(
|
|
370
|
+
"SSE start must return a cleanup function, a closeable subscription, or nothing.",
|
|
371
|
+
),
|
|
372
|
+
);
|
|
373
|
+
close();
|
|
374
|
+
}
|
|
375
|
+
} catch (error) {
|
|
376
|
+
reportError(error);
|
|
377
|
+
close();
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const handleStartError = (error: unknown): void => {
|
|
382
|
+
if (!(closed && isAbortError(error))) reportError(error);
|
|
383
|
+
close();
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
const result = options.start(controls);
|
|
388
|
+
if (isPromiseLike<ServerSentEventCleanup | undefined>(result)) {
|
|
389
|
+
void Promise.resolve(result).then(
|
|
390
|
+
registerCleanup,
|
|
391
|
+
handleStartError,
|
|
392
|
+
);
|
|
393
|
+
} else {
|
|
394
|
+
registerCleanup(result);
|
|
395
|
+
}
|
|
396
|
+
} catch (error) {
|
|
397
|
+
handleStartError(error);
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
cancel() {
|
|
401
|
+
closeStream?.();
|
|
402
|
+
return runCleanup();
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
highWaterMark: maxBufferedBytes,
|
|
407
|
+
size: (chunk) => chunk?.byteLength ?? 0,
|
|
408
|
+
},
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
return new Response(stream, {
|
|
412
|
+
status: 200,
|
|
413
|
+
headers,
|
|
414
|
+
});
|
|
415
|
+
}
|
package/src/server/server.ts
CHANGED
|
@@ -78,7 +78,10 @@ import {
|
|
|
78
78
|
type TrustedProxyConfig,
|
|
79
79
|
type TrustedRequestInfo,
|
|
80
80
|
} from "./trusted-proxy.js";
|
|
81
|
-
import {
|
|
81
|
+
import {
|
|
82
|
+
assertValidTrustedProxyConfig,
|
|
83
|
+
parseHttpRequestUrl,
|
|
84
|
+
} from "./trusted-proxy-internal.js";
|
|
82
85
|
import {
|
|
83
86
|
createUseCaseRouteHandler,
|
|
84
87
|
isUseCaseRouteDef,
|
|
@@ -539,7 +542,7 @@ export async function createServer<
|
|
|
539
542
|
): Promise<unknown> => {
|
|
540
543
|
if (!serviceContextsAvailable) {
|
|
541
544
|
throw new Error(
|
|
542
|
-
"Service contexts are unavailable
|
|
545
|
+
"Service contexts are unavailable during provider setup and after provider shutdown.",
|
|
543
546
|
);
|
|
544
547
|
}
|
|
545
548
|
|
|
@@ -553,16 +556,20 @@ export async function createServer<
|
|
|
553
556
|
if (stopped) return;
|
|
554
557
|
stopped = true;
|
|
555
558
|
const errors: unknown[] = [];
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
559
|
+
try {
|
|
560
|
+
for (let i = providerResults.length - 1; i >= 0; i -= 1) {
|
|
561
|
+
const result = providerResults[i];
|
|
562
|
+
try {
|
|
563
|
+
await result?.stop?.({
|
|
564
|
+
ports: finalPorts,
|
|
565
|
+
createServiceContext: lifecycleCreateServiceContext,
|
|
566
|
+
});
|
|
567
|
+
} catch (err) {
|
|
568
|
+
errors.push(err);
|
|
569
|
+
}
|
|
565
570
|
}
|
|
571
|
+
} finally {
|
|
572
|
+
serviceContextsAvailable = false;
|
|
566
573
|
}
|
|
567
574
|
if (errors.length) {
|
|
568
575
|
throw new AggregateError(errors, "Provider shutdown errors");
|
|
@@ -744,16 +751,6 @@ export async function createServer<
|
|
|
744
751
|
providerResults.push(result);
|
|
745
752
|
}
|
|
746
753
|
|
|
747
|
-
for (const result of providerResults) {
|
|
748
|
-
if (!result.start) continue;
|
|
749
|
-
await result.start({
|
|
750
|
-
ports: finalPorts,
|
|
751
|
-
createServiceContext: lifecycleCreateServiceContext,
|
|
752
|
-
});
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
instrumentation.attachPorts(finalPorts);
|
|
756
|
-
|
|
757
754
|
const onUnboundPorts = options.onUnboundPorts ?? "error";
|
|
758
755
|
if (onUnboundPorts !== "ignore") {
|
|
759
756
|
const unboundKeys = Object.keys(finalPorts).filter((key) =>
|
|
@@ -772,7 +769,20 @@ export async function createServer<
|
|
|
772
769
|
}
|
|
773
770
|
}
|
|
774
771
|
|
|
772
|
+
// Every provider has now contributed its ports and the unbound-port guard
|
|
773
|
+
// has passed. Start hooks may safely build service contexts while they
|
|
774
|
+
// activate consumers such as event listeners.
|
|
775
775
|
serviceContextsAvailable = true;
|
|
776
|
+
|
|
777
|
+
for (const result of providerResults) {
|
|
778
|
+
if (!result.start) continue;
|
|
779
|
+
await result.start({
|
|
780
|
+
ports: finalPorts,
|
|
781
|
+
createServiceContext: lifecycleCreateServiceContext,
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
instrumentation.attachPorts(finalPorts);
|
|
776
786
|
} catch (error) {
|
|
777
787
|
try {
|
|
778
788
|
await stop();
|
|
@@ -807,6 +817,7 @@ export async function createServer<
|
|
|
807
817
|
path,
|
|
808
818
|
pathParams: null,
|
|
809
819
|
query: null,
|
|
820
|
+
queryTransport: null,
|
|
810
821
|
body: null,
|
|
811
822
|
responses: {},
|
|
812
823
|
metadata: {},
|
|
@@ -821,8 +832,22 @@ export async function createServer<
|
|
|
821
832
|
registryNeedsSort = false;
|
|
822
833
|
}
|
|
823
834
|
|
|
824
|
-
const url = new URL(req.url);
|
|
825
835
|
const method = req.method.toUpperCase();
|
|
836
|
+
let url: URL;
|
|
837
|
+
try {
|
|
838
|
+
url = parseHttpRequestUrl(req.url);
|
|
839
|
+
} catch (error) {
|
|
840
|
+
return await executeFallback(
|
|
841
|
+
{
|
|
842
|
+
contract: fallbackContract("invalidRequestUrl", method, "/"),
|
|
843
|
+
handler: async () => {
|
|
844
|
+
throw error;
|
|
845
|
+
},
|
|
846
|
+
},
|
|
847
|
+
req,
|
|
848
|
+
{},
|
|
849
|
+
);
|
|
850
|
+
}
|
|
826
851
|
|
|
827
852
|
let pathMatchedMethods: Set<string> | undefined;
|
|
828
853
|
let headCandidate: RegisteredRoute | undefined;
|
|
@@ -929,6 +954,7 @@ function rawRouteContract(init: RawRouteInit): HttpContractConfig {
|
|
|
929
954
|
path: init.path,
|
|
930
955
|
pathParams: null,
|
|
931
956
|
query: null,
|
|
957
|
+
queryTransport: null,
|
|
932
958
|
body: null,
|
|
933
959
|
responses: {},
|
|
934
960
|
metadata: init.metadata ?? {},
|
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
import type { TrustedProxyConfig } from "./trusted-proxy.js";
|
|
2
2
|
|
|
3
|
+
export class InvalidRequestUrlError extends Error {
|
|
4
|
+
constructor() {
|
|
5
|
+
super("req.url must be an absolute HTTP or HTTPS URL.");
|
|
6
|
+
this.name = "InvalidRequestUrlError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function parseHttpRequestUrl(value: string): URL {
|
|
11
|
+
try {
|
|
12
|
+
const url = new URL(value);
|
|
13
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
14
|
+
throw new InvalidRequestUrlError();
|
|
15
|
+
}
|
|
16
|
+
return url;
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (error instanceof InvalidRequestUrlError) throw error;
|
|
19
|
+
throw new InvalidRequestUrlError();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
3
23
|
export function requireTrustedProxyHeaderName(
|
|
4
24
|
name: string,
|
|
5
25
|
optionName: string,
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { HttpRequestLike } from "./http.js";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
parseHttpRequestUrl,
|
|
4
|
+
requireTrustedProxyHeaderName,
|
|
5
|
+
} from "./trusted-proxy-internal.js";
|
|
3
6
|
|
|
4
7
|
/**
|
|
5
8
|
* Header source used to resolve a client IP after an app has explicitly opted
|
|
@@ -141,17 +144,13 @@ function normalizeHost(
|
|
|
141
144
|
}
|
|
142
145
|
|
|
143
146
|
function baseRequestUrl(req: HttpRequestLike): URL {
|
|
144
|
-
|
|
145
|
-
return new URL(req.url);
|
|
146
|
-
} catch {
|
|
147
|
-
throw new Error("req.url must be an absolute URL.");
|
|
148
|
-
}
|
|
147
|
+
return parseHttpRequestUrl(req.url);
|
|
149
148
|
}
|
|
150
149
|
|
|
151
150
|
function normalizedBaseProtocol(url: URL): "http" | "https" {
|
|
152
151
|
const protocol = normalizeProtocol(url.protocol);
|
|
153
152
|
if (protocol) return protocol;
|
|
154
|
-
throw new Error("
|
|
153
|
+
throw new Error("Resolved request URL must use HTTP or HTTPS.");
|
|
155
154
|
}
|
|
156
155
|
|
|
157
156
|
/**
|