@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.
Files changed (145) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +213 -32
  3. package/dist/application/index.d.ts +5 -5
  4. package/dist/application/index.d.ts.map +1 -1
  5. package/dist/application/index.js +5 -4
  6. package/dist/application/index.js.map +1 -1
  7. package/dist/events/index.d.ts +54 -5
  8. package/dist/events/index.d.ts.map +1 -1
  9. package/dist/events/index.js +188 -34
  10. package/dist/events/index.js.map +1 -1
  11. package/dist/events/payload-state.d.ts +4 -0
  12. package/dist/events/payload-state.d.ts.map +1 -0
  13. package/dist/events/payload-state.js +11 -0
  14. package/dist/events/payload-state.js.map +1 -0
  15. package/dist/idempotency/index.d.ts +7 -3
  16. package/dist/idempotency/index.d.ts.map +1 -1
  17. package/dist/idempotency/index.js +45 -12
  18. package/dist/idempotency/index.js.map +1 -1
  19. package/dist/locks/index.d.ts.map +1 -1
  20. package/dist/locks/index.js +0 -4
  21. package/dist/locks/index.js.map +1 -1
  22. package/dist/mail/index.d.ts.map +1 -1
  23. package/dist/mail/index.js +6 -3
  24. package/dist/mail/index.js.map +1 -1
  25. package/dist/outbox/index.d.ts +163 -15
  26. package/dist/outbox/index.d.ts.map +1 -1
  27. package/dist/outbox/index.js +1009 -152
  28. package/dist/outbox/index.js.map +1 -1
  29. package/dist/payments/index.d.ts.map +1 -1
  30. package/dist/payments/index.js +0 -4
  31. package/dist/payments/index.js.map +1 -1
  32. package/dist/ports/best-effort-work.d.ts +21 -0
  33. package/dist/ports/best-effort-work.d.ts.map +1 -0
  34. package/dist/ports/best-effort-work.js +2 -0
  35. package/dist/ports/best-effort-work.js.map +1 -0
  36. package/dist/ports/events.d.ts +7 -5
  37. package/dist/ports/events.d.ts.map +1 -1
  38. package/dist/ports/index.d.ts +7 -2
  39. package/dist/ports/index.d.ts.map +1 -1
  40. package/dist/ports/index.js +1 -0
  41. package/dist/ports/index.js.map +1 -1
  42. package/dist/ports/testing.d.ts +28 -0
  43. package/dist/ports/testing.d.ts.map +1 -1
  44. package/dist/ports/testing.js +50 -0
  45. package/dist/ports/testing.js.map +1 -1
  46. package/dist/ports/unit-of-work.d.ts +9 -7
  47. package/dist/ports/unit-of-work.d.ts.map +1 -1
  48. package/dist/ports/unit-of-work.js +16 -6
  49. package/dist/ports/unit-of-work.js.map +1 -1
  50. package/dist/providers/index.d.ts +1 -1
  51. package/dist/providers/index.d.ts.map +1 -1
  52. package/dist/providers/index.js.map +1 -1
  53. package/dist/providers/provider.d.ts +8 -43
  54. package/dist/providers/provider.d.ts.map +1 -1
  55. package/dist/providers/provider.js.map +1 -1
  56. package/dist/search/index.d.ts.map +1 -1
  57. package/dist/search/index.js +0 -4
  58. package/dist/search/index.js.map +1 -1
  59. package/dist/server/hooks/cors.d.ts +7 -2
  60. package/dist/server/hooks/cors.d.ts.map +1 -1
  61. package/dist/server/hooks/cors.js +31 -2
  62. package/dist/server/hooks/cors.js.map +1 -1
  63. package/dist/server/hooks/logging.d.ts +2 -2
  64. package/dist/server/hooks/logging.d.ts.map +1 -1
  65. package/dist/server/hooks/logging.js.map +1 -1
  66. package/dist/server/hooks/security.d.ts +2 -2
  67. package/dist/server/hooks/security.d.ts.map +1 -1
  68. package/dist/server/hooks/security.js.map +1 -1
  69. package/dist/server/http.d.ts +21 -2
  70. package/dist/server/http.d.ts.map +1 -1
  71. package/dist/server/index.d.ts +4 -0
  72. package/dist/server/index.d.ts.map +1 -1
  73. package/dist/server/index.js +4 -0
  74. package/dist/server/index.js.map +1 -1
  75. package/dist/server/instrumentation.d.ts.map +1 -1
  76. package/dist/server/instrumentation.js +5 -3
  77. package/dist/server/instrumentation.js.map +1 -1
  78. package/dist/server/request-executor.d.ts.map +1 -1
  79. package/dist/server/request-executor.js +9 -0
  80. package/dist/server/request-executor.js.map +1 -1
  81. package/dist/server/request-preparation.d.ts.map +1 -1
  82. package/dist/server/request-preparation.js +20 -2
  83. package/dist/server/request-preparation.js.map +1 -1
  84. package/dist/server/response-finalization.d.ts +2 -2
  85. package/dist/server/response-finalization.d.ts.map +1 -1
  86. package/dist/server/response-finalization.js +25 -8
  87. package/dist/server/response-finalization.js.map +1 -1
  88. package/dist/server/route-matching.d.ts.map +1 -1
  89. package/dist/server/route-matching.js +12 -1
  90. package/dist/server/route-matching.js.map +1 -1
  91. package/dist/server/server-sent-events.d.ts +94 -0
  92. package/dist/server/server-sent-events.d.ts.map +1 -0
  93. package/dist/server/server-sent-events.js +275 -0
  94. package/dist/server/server-sent-events.js.map +1 -0
  95. package/dist/server/server.d.ts.map +1 -1
  96. package/dist/server/server.js +75 -31
  97. package/dist/server/server.js.map +1 -1
  98. package/dist/server/trusted-proxy-internal.d.ts +4 -0
  99. package/dist/server/trusted-proxy-internal.d.ts.map +1 -1
  100. package/dist/server/trusted-proxy-internal.js +20 -0
  101. package/dist/server/trusted-proxy-internal.js.map +1 -1
  102. package/dist/server/trusted-proxy.d.ts.map +1 -1
  103. package/dist/server/trusted-proxy.js +3 -8
  104. package/dist/server/trusted-proxy.js.map +1 -1
  105. package/dist/testing/index.d.ts +17 -0
  106. package/dist/testing/index.d.ts.map +1 -1
  107. package/dist/testing/index.js +18 -7
  108. package/dist/testing/index.js.map +1 -1
  109. package/package.json +1 -1
  110. package/skills/app-architecture/SKILL.md +37 -3
  111. package/src/application/index.ts +18 -7
  112. package/src/events/index.ts +268 -40
  113. package/src/events/payload-state.ts +24 -0
  114. package/src/idempotency/index.ts +65 -17
  115. package/src/locks/index.ts +0 -4
  116. package/src/mail/index.ts +7 -3
  117. package/src/outbox/index.ts +1382 -175
  118. package/src/payments/index.ts +0 -4
  119. package/src/ports/best-effort-work.ts +21 -0
  120. package/src/ports/events.ts +9 -4
  121. package/src/ports/index.ts +12 -0
  122. package/src/ports/testing.ts +76 -0
  123. package/src/ports/unit-of-work.ts +28 -14
  124. package/src/providers/index.ts +0 -1
  125. package/src/providers/provider.ts +8 -45
  126. package/src/search/index.ts +0 -4
  127. package/src/server/hooks/cors.ts +53 -5
  128. package/src/server/hooks/logging.ts +6 -2
  129. package/src/server/hooks/security.ts +8 -4
  130. package/src/server/http.ts +23 -2
  131. package/src/server/index.ts +4 -0
  132. package/src/server/instrumentation.ts +12 -4
  133. package/src/server/request-executor.ts +14 -0
  134. package/src/server/request-preparation.ts +23 -3
  135. package/src/server/response-finalization.ts +51 -15
  136. package/src/server/route-matching.ts +24 -1
  137. package/src/server/server-sent-events.ts +415 -0
  138. package/src/server/server.ts +98 -41
  139. package/src/server/trusted-proxy-internal.ts +20 -0
  140. package/src/server/trusted-proxy.ts +6 -7
  141. package/src/testing/index.ts +50 -9
  142. package/dist/query-codec.d.ts +0 -27
  143. package/dist/query-codec.d.ts.map +0 -1
  144. package/dist/query-codec.js +0 -245
  145. 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 { HttpRequestLike, HttpResponseLike, ServerHook } from "./http.js";
19
+ import type {
20
+ HttpRequestLike,
21
+ HttpResponseHeaders,
22
+ HttpResponseLike,
23
+ ServerHook,
24
+ } from "./http.js";
20
25
  import {
21
26
  clearActiveRequestContext,
22
27
  enterActiveRequestContext,
@@ -205,17 +210,20 @@ function requestHeadersToRecord(headers: Headers): Record<string, string> {
205
210
  }
206
211
 
207
212
  function getResponseHeader(
208
- headers: Record<string, string> | undefined,
213
+ headers: HttpResponseHeaders | undefined,
209
214
  name: string,
210
215
  ): string | undefined {
211
216
  if (!headers) return undefined;
212
217
  const direct = headers[name];
213
- if (direct !== undefined) return direct;
218
+ if (direct !== undefined) {
219
+ return typeof direct === "string" ? direct : direct[0];
220
+ }
214
221
  const normalized = name.toLowerCase();
215
222
  const entry = Object.entries(headers).find(
216
223
  ([key]) => key.toLowerCase() === normalized,
217
224
  );
218
- return entry?.[1];
225
+ const value = entry?.[1];
226
+ return typeof value === "string" ? value : value?.[0];
219
227
  }
220
228
 
221
229
  function getResponseOwner(
@@ -82,6 +82,7 @@ import {
82
82
  PathDecodeError,
83
83
  } from "./route-matching.js";
84
84
  import type { TrustedRequestInfo } from "./trusted-proxy.js";
85
+ import { InvalidRequestUrlError } from "./trusted-proxy-internal.js";
85
86
 
86
87
  function withoutHeadResponseBody(
87
88
  response: HttpResponse,
@@ -281,6 +282,19 @@ export function createRequestExecutor<
281
282
  };
282
283
  }
283
284
 
285
+ if (currentError instanceof InvalidRequestUrlError) {
286
+ return {
287
+ ctx,
288
+ response: errorResponse(
289
+ 400,
290
+ "INVALID_REQUEST_URL",
291
+ "Malformed request URL",
292
+ ),
293
+ error: currentError,
294
+ owner: "framework",
295
+ };
296
+ }
297
+
284
298
  if (isAppError(currentError)) {
285
299
  return {
286
300
  ctx,
@@ -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
- throw new RequestBodyTooLargeError(maxBytes, received);
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 { HttpResponse, HttpResponseLike } from "./http.js";
12
+ import type {
13
+ HttpResponse,
14
+ HttpResponseHeaders,
15
+ HttpResponseLike,
16
+ } from "./http.js";
13
17
  import type { ResponseFinalizerResponseOwner } from "./internal-hooks.js";
14
18
  import {
15
19
  parseStandardSchema,
@@ -76,7 +80,7 @@ export function withFrameworkErrorOwnerHeader(
76
80
  }
77
81
 
78
82
  function setRecordHeader(
79
- headers: Record<string, string>,
83
+ headers: HttpResponseHeaders,
80
84
  name: string,
81
85
  value: string,
82
86
  ): void {
@@ -89,6 +93,34 @@ function setRecordHeader(
89
93
  headers[name] = value;
90
94
  }
91
95
 
96
+ function headerValues(value: string | readonly string[]): readonly string[] {
97
+ return typeof value === "string" ? [value] : value;
98
+ }
99
+
100
+ function sameHeaderValue(
101
+ left: string | readonly string[] | undefined,
102
+ right: string | readonly string[],
103
+ ): boolean {
104
+ if (left === undefined) return false;
105
+ const leftValues = headerValues(left);
106
+ const rightValues = headerValues(right);
107
+ return (
108
+ leftValues.length === rightValues.length &&
109
+ leftValues.every((value, index) => value === rightValues[index])
110
+ );
111
+ }
112
+
113
+ function appendHeaderValues(
114
+ headers: Headers,
115
+ name: string,
116
+ value: string | readonly string[],
117
+ ): void {
118
+ headers.delete(name);
119
+ for (const item of headerValues(value)) {
120
+ headers.append(name, item);
121
+ }
122
+ }
123
+
92
124
  /** Apply contract-owned deprecation headers to any response representation. */
93
125
  export function withContractLifecycleHeaders(
94
126
  res: HttpResponse,
@@ -113,17 +145,18 @@ export function withContractLifecycleHeaders(
113
145
  });
114
146
  }
115
147
 
116
- const headers = { ...(res.headers ?? {}) };
148
+ const headers: HttpResponseHeaders = { ...(res.headers ?? {}) };
117
149
  for (const [name, value] of Object.entries(lifecycleHeaders)) {
118
150
  if (name.toLowerCase() === "link") {
119
151
  const existingName = Object.keys(headers).find(
120
152
  (key) => key.toLowerCase() === "link",
121
153
  );
122
154
  const existing = existingName ? headers[existingName] : undefined;
155
+ const existingValue = existing ? headerValues(existing).join(", ") : "";
123
156
  setRecordHeader(
124
157
  headers,
125
158
  name,
126
- existing ? `${existing}, ${value}` : value,
159
+ existingValue ? `${existingValue}, ${value}` : value,
127
160
  );
128
161
  } else {
129
162
  setRecordHeader(headers, name, value);
@@ -140,11 +173,18 @@ export function responseOwnerFor(
140
173
  return owner ?? "route";
141
174
  }
142
175
 
143
- function headersToRecord(headers: Headers): Record<string, string> {
144
- const record: Record<string, string> = {};
176
+ function headersToRecord(headers: Headers): HttpResponseHeaders {
177
+ const record: HttpResponseHeaders = {};
145
178
  headers.forEach((value, key) => {
146
179
  record[key] = value;
147
180
  });
181
+ const setCookies =
182
+ (headers as Headers & { getSetCookie?: () => string[] }).getSetCookie?.call(
183
+ headers,
184
+ ) ?? [];
185
+ if (setCookies.length > 0) {
186
+ record["set-cookie"] = setCookies;
187
+ }
148
188
  return record;
149
189
  }
150
190
 
@@ -169,10 +209,10 @@ export function responseForHooks(res: HttpResponse): HttpResponseLike {
169
209
  */
170
210
  export function mergeNativeResponseHeaders(
171
211
  nativeResponse: Response,
172
- originalHeaders: Record<string, string>,
173
- finalHeaders: Record<string, string>,
212
+ originalHeaders: HttpResponseHeaders,
213
+ finalHeaders: HttpResponseHeaders,
174
214
  ): Response {
175
- const originalByLowerKey = new Map<string, string>();
215
+ const originalByLowerKey = new Map<string, string | readonly string[]>();
176
216
  for (const [key, value] of Object.entries(originalHeaders)) {
177
217
  originalByLowerKey.set(key.toLowerCase(), value);
178
218
  }
@@ -181,13 +221,9 @@ export function mergeNativeResponseHeaders(
181
221
  const merged = new Headers(nativeResponse.headers);
182
222
  for (const [key, value] of Object.entries(finalHeaders)) {
183
223
  const lowerKey = key.toLowerCase();
184
- if (originalByLowerKey.get(lowerKey) === value) continue;
224
+ if (sameHeaderValue(originalByLowerKey.get(lowerKey), value)) continue;
185
225
  changed = true;
186
- if (lowerKey === "set-cookie") {
187
- merged.append(lowerKey, value);
188
- } else {
189
- merged.set(key, value);
190
- }
226
+ appendHeaderValues(merged, key, value);
191
227
  }
192
228
 
193
229
  if (!changed) {
@@ -15,12 +15,35 @@ export class PathDecodeError extends Error {
15
15
  }
16
16
  }
17
17
 
18
+ function encodeStaticSegment(value: string): string {
19
+ return encodeURI(value).replace(
20
+ /[?#]/g,
21
+ (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
22
+ );
23
+ }
24
+
25
+ function matchPercentEncodingCase(value: string): string {
26
+ return value.replace(/%([0-9A-F]{2})/g, (_match, encoded: string) => {
27
+ const digits = [...encoded]
28
+ .map((digit) =>
29
+ /[A-F]/.test(digit) ? `[${digit}${digit.toLowerCase()}]` : digit,
30
+ )
31
+ .join("");
32
+ return `%${digits}`;
33
+ });
34
+ }
35
+
18
36
  export function compilePath(path: string): CompiledPath {
19
37
  const parsed = parsePathTemplate(path);
20
38
  const regexParts = parsed.segments.map((segment) =>
21
39
  segment.kind === "dynamic"
22
40
  ? "([^/]+)"
23
- : segment.value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
41
+ : matchPercentEncodingCase(
42
+ encodeStaticSegment(segment.value).replace(
43
+ /[.*+?^${}()|[\]\\]/g,
44
+ "\\$&",
45
+ ),
46
+ ),
24
47
  );
25
48
  const pattern = new RegExp(`^/${regexParts.join("/")}$`);
26
49
  return { ...parsed, pattern };
@@ -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
+ }