@beignet/core 0.0.49 → 0.0.51
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 +30 -0
- package/README.md +213 -32
- package/dist/application/index.d.ts +5 -5
- package/dist/application/index.d.ts.map +1 -1
- package/dist/application/index.js +5 -4
- package/dist/application/index.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 +188 -34
- package/dist/events/index.js.map +1 -1
- package/dist/events/payload-state.d.ts +4 -0
- package/dist/events/payload-state.d.ts.map +1 -0
- package/dist/events/payload-state.js +11 -0
- package/dist/events/payload-state.js.map +1 -0
- 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/locks/index.d.ts.map +1 -1
- package/dist/locks/index.js +0 -4
- package/dist/locks/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/outbox/index.d.ts +163 -15
- package/dist/outbox/index.d.ts.map +1 -1
- package/dist/outbox/index.js +1009 -152
- package/dist/outbox/index.js.map +1 -1
- package/dist/payments/index.d.ts.map +1 -1
- package/dist/payments/index.js +0 -4
- package/dist/payments/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 +7 -2
- 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 +28 -0
- package/dist/ports/testing.d.ts.map +1 -1
- package/dist/ports/testing.js +50 -0
- package/dist/ports/testing.js.map +1 -1
- package/dist/ports/unit-of-work.d.ts +9 -7
- package/dist/ports/unit-of-work.d.ts.map +1 -1
- package/dist/ports/unit-of-work.js +16 -6
- package/dist/ports/unit-of-work.js.map +1 -1
- package/dist/providers/index.d.ts +1 -1
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/provider.d.ts +8 -43
- package/dist/providers/provider.d.ts.map +1 -1
- package/dist/providers/provider.js.map +1 -1
- package/dist/search/index.d.ts.map +1 -1
- package/dist/search/index.js +0 -4
- package/dist/search/index.js.map +1 -1
- package/dist/server/hooks/cors.d.ts +7 -2
- package/dist/server/hooks/cors.d.ts.map +1 -1
- package/dist/server/hooks/cors.js +31 -2
- 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 +75 -31
- 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 +18 -7
- package/dist/testing/index.js.map +1 -1
- package/package.json +1 -1
- package/skills/app-architecture/SKILL.md +37 -3
- package/src/application/index.ts +18 -7
- package/src/events/index.ts +268 -40
- package/src/events/payload-state.ts +24 -0
- package/src/idempotency/index.ts +65 -17
- package/src/locks/index.ts +0 -4
- package/src/mail/index.ts +7 -3
- package/src/outbox/index.ts +1382 -175
- package/src/payments/index.ts +0 -4
- package/src/ports/best-effort-work.ts +21 -0
- package/src/ports/events.ts +9 -4
- package/src/ports/index.ts +12 -0
- package/src/ports/testing.ts +76 -0
- package/src/ports/unit-of-work.ts +28 -14
- package/src/providers/index.ts +0 -1
- package/src/providers/provider.ts +8 -45
- package/src/search/index.ts +0 -4
- package/src/server/hooks/cors.ts +53 -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 +98 -41
- package/src/server/trusted-proxy-internal.ts +20 -0
- package/src/server/trusted-proxy.ts +6 -7
- package/src/testing/index.ts +50 -9
- 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
|
@@ -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
|
}
|
|
@@ -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 };
|
|
@@ -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
|
+
}
|