@bakery-framework/core 1.1.0 → 1.2.0

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/src/global.d.ts CHANGED
@@ -44,15 +44,30 @@ declare global {
44
44
  inBody?: boolean
45
45
  }
46
46
 
47
+ /**
48
+ * What a middleware may return to stop the chain: a `Response`, or a
49
+ * `response.json.*` envelope. Anything else — including a bare object or a
50
+ * string — is ignored and the next middleware runs. See `$middleware.ts`.
51
+ */
52
+ type MiddlewareResponse =
53
+ | Response
54
+ | import('./utils/common/json').JsonResponseData
55
+
47
56
  type HostEntry = {
48
57
  root?: string
49
58
  importMap?: Record<string, string>
50
59
  middleware?: ((
51
60
  req: Request,
52
61
  server: Bun.Server<any>,
53
- ) => MixedPromise<Response | void>)[]
62
+ ) => MixedPromise<MiddlewareResponse | void>)[]
54
63
  onRequest?(req: Request): MixedPromise<any>
55
64
  onError?(error: Handler.Error.Data): MixedPromise<any>
65
+ /**
66
+ * Cross-origin resource sharing. Absent means no CORS headers at all,
67
+ * which is the browser default and the safe one — there is deliberately no
68
+ * permissive default, not even in development.
69
+ */
70
+ cors?: import('./utils/http/cors').CorsOptions | null
56
71
  head?: string
57
72
  body?: string
58
73
  proxy?: Record<string, string>
@@ -79,6 +94,12 @@ declare global {
79
94
 
80
95
  proxy?: Record<string, string>
81
96
 
97
+ /**
98
+ * Cross-origin resource sharing. Absent means no CORS headers at all,
99
+ * which is the browser default and the safe one — there is deliberately no
100
+ * permissive default, not even in development.
101
+ */
102
+ cors?: import('./utils/http/cors').CorsOptions | null
82
103
  head?: string
83
104
 
84
105
  body?: string
@@ -94,7 +115,7 @@ declare global {
94
115
  middleware?: ((
95
116
  req: Request,
96
117
  server: Bun.Server<any>,
97
- ) => MixedPromise<Response | void>)[]
118
+ ) => MixedPromise<MiddlewareResponse | void>)[]
98
119
 
99
120
  plugins?: ServerPlugin[]
100
121
 
@@ -1,15 +1,39 @@
1
1
  import { Bakery } from '../../core/bakery'
2
2
  import { errorMsg, handlerLog } from '../../logger/serve-log'
3
+ import { JsonResponseData } from '../../utils/common/json'
3
4
  import { injectIfHtml, response } from '../../utils/http'
4
5
  import { Handler } from './$base'
5
6
 
7
+ /**
8
+ * What a middleware may return to stop the chain.
9
+ *
10
+ * A `Response` and a `response.json.*` envelope, and deliberately nothing
11
+ * else. `JsonResponseData` is the framework's one-envelope idiom (convention
12
+ * 7) and `processResponse` already renders it with its own `status`, so an
13
+ * `ApiHandler` route returning `response.json.error(401, …)` answered 401
14
+ * while the identical line in a middleware did not — the value was not a
15
+ * `Response`, so the chain ignored it and the request carried on.
16
+ *
17
+ * Widening further, to "anything truthy", is the tempting version and is
18
+ * wrong: a middleware that returns a stray string or object would then halt
19
+ * the request by accident, and returning a value is how middleware signals
20
+ * *nothing* in plenty of code (`arr.map`, an assignment expression, an
21
+ * implicit arrow return). Two named shapes, both of which mean "I am the
22
+ * response".
23
+ */
24
+ type MiddlewareResult = Response | JsonResponseData
25
+
26
+ function isMiddlewareResult(value: unknown): value is MiddlewareResult {
27
+ return value instanceof Response || value instanceof JsonResponseData
28
+ }
29
+
6
30
  /**
7
31
  * Per-request slot for the response produced during `canHandle`, so `handle`
8
32
  * can return it without re-running the chain. This was previously a static
9
33
  * field, which meant two concurrent requests could swap responses — including
10
34
  * each other's `Set-Cookie` headers — at any `await` boundary.
11
35
  */
12
- const pending = new WeakMap<Request, Response>()
36
+ const pending = new WeakMap<Request, MiddlewareResult>()
13
37
 
14
38
  export class MiddlewareHandler extends Handler {
15
39
  /** Answers from app code, not from disk. See `Handler.servesFiles`. */
@@ -48,12 +72,12 @@ export class MiddlewareHandler extends Handler {
48
72
  return (await injectIfHtml(intercepted)) || intercepted
49
73
  }
50
74
 
51
- let data: any
75
+ let data: MiddlewareResult | undefined
52
76
 
53
77
  for (const middleware of config.middleware) {
54
78
  try {
55
79
  const result = await middleware(req, Bakery.server!)
56
- if (result instanceof Response) {
80
+ if (isMiddlewareResult(result)) {
57
81
  data = result
58
82
  break
59
83
  }
@@ -65,7 +89,12 @@ export class MiddlewareHandler extends Handler {
65
89
  }
66
90
  }
67
91
 
68
- const injectedRes = await injectIfHtml(data)
92
+ // An envelope is JSON by construction, so it skips injection rather than
93
+ // paying `DOMTools.isHTML` to be told so. `processResponse` reads its
94
+ // `status` and serialises it.
95
+ if (data instanceof JsonResponseData) return data
96
+
97
+ const injectedRes = await injectIfHtml(data!)
69
98
  return injectedRes || data
70
99
  }
71
100
  }
@@ -25,6 +25,17 @@ const serveMsgs = {
25
25
  WATCHER_ERR: 'E Watcher error: %r{error}%*',
26
26
  TSCONFIG_SYNCED:
27
27
  'I Synced %ytsconfig.json%* paths with %yserver.config.ts%*!',
28
+ TSCONFIG_PROJECTS_WRITTEN:
29
+ 'I Wrote %y{count}%* tsconfig project(s) to %y.cache/tsconfig/%*',
30
+ // A plugin asking for a project name that is taken. Named rather than
31
+ // silent: the symptom otherwise is one plugin's types quietly not applying,
32
+ // discovered much later and blamed on the wrong thing.
33
+ TSCONFIG_PROJECT_CLASH:
34
+ 'W Plugin %y{plugin}%* wants tsconfig project %y{project}%*, which already exists — %rskipped%*',
35
+ // Degrades to "no types" rather than failing the boot: a missing declaration
36
+ // is a worse editor experience, not a broken server.
37
+ TSCONFIG_FILE_UNRESOLVED:
38
+ 'W Could not resolve tsconfig files entry %y{entry}%* — %rskipped%*',
28
39
  MANUAL_RELOAD: 'I %yManual reload%* triggered from client logger!',
29
40
  CONFIG_IMPORT_ERR: 'E Failed to import %yserver.config.ts%*: %r{error}%*',
30
41
  // Multi-line on purpose: a present-but-broken config booting on defaults is
@@ -3,9 +3,68 @@ import type { MixedPromise } from '../types'
3
3
 
4
4
  export type ValidResponses = Handler.Response
5
5
 
6
+ /**
7
+ * A TypeScript project a plugin contributes to the app.
8
+ *
9
+ * Written to `.cache/tsconfig/<name>.json` on every dev boot and referenced
10
+ * from the app's root `tsconfig.json`, so the editor typechecks each file under
11
+ * the project that owns it. Regenerated rather than committed: it is derived
12
+ * from the plugin list and the app's config, and `.cache/` is the disposable
13
+ * half of the two runtime directories.
14
+ */
15
+ export interface PluginTsProject {
16
+ /** File name under `.cache/tsconfig/`, and the project's identity. */
17
+ name: string
18
+ /** Usually one of core's three bases. Written through as-is. */
19
+ extends: string
20
+ /** Globs, relative to the **app root** — the generator rewrites them. */
21
+ include?: string[]
22
+ exclude?: string[]
23
+ /**
24
+ * Ambient declarations the plugin owns.
25
+ *
26
+ * Package specifiers are allowed here and resolved to real paths before
27
+ * writing, because TypeScript resolves `files` as paths and would treat
28
+ * `@scope/pkg/x.d.ts` as a missing file rather than a module.
29
+ */
30
+ files?: string[]
31
+ compilerOptions?: Record<string, unknown>
32
+ /**
33
+ * Whether this project should receive `paths` derived from `importMap`.
34
+ *
35
+ * Off by default, and that default is the correction: the generator used to
36
+ * write those `paths` into *every* project on the reasoning that an alias is
37
+ * app-wide. It is not. `importMap` is a **browser** import map — the framework
38
+ * serves it as `<script type="importmap">` and the browser is what resolves
39
+ * its specifiers. An alias in the server project therefore typechecks an
40
+ * import that only the browser can satisfy, which is the same class of bug the
41
+ * server/client split was introduced to end.
42
+ *
43
+ * A plugin whose project compiles browser code should set it.
44
+ */
45
+ importMapPaths?: boolean
46
+ }
47
+
48
+ /** What a plugin contributes to the generated tsconfig projects. */
49
+ export interface PluginTsConfig {
50
+ /** A project this plugin owns outright — Vue SFCs, for instance. */
51
+ project?: PluginTsProject
52
+ }
53
+
6
54
  export interface ServerPlugin {
7
55
  name: string
8
56
  setup?(config: ProcessedAppConfig): MixedPromise<void>
57
+
58
+ /**
59
+ * Declarative, unlike every hook below it: read by the tsconfig generator at
60
+ * dev boot, never by the running server.
61
+ *
62
+ * A plugin that brings its own file type or its own ambient globals needs a
63
+ * project to typecheck them under. `@bakery-framework/plugin-vue` is the
64
+ * worked example — it owns `.vue` and declares `req`/`body` for SFC scope,
65
+ * globals core deliberately does not provide.
66
+ */
67
+ tsconfig?: PluginTsConfig
9
68
  onStart?(server: Bun.Server<any>): MixedPromise<void>
10
69
  onRequest?(req: Request): ValidResponses
11
70
  onRoute?(req: Request): MixedPromise<void>
package/src/router.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  response,
19
19
  withStatus,
20
20
  } from './utils/http'
21
+ import { applyCors, preflightResponse } from './utils/http/cors'
21
22
 
22
23
  /**
23
24
  * Resolve a WebSocket upgrade, refusing cross-origin handshakes first.
@@ -97,6 +98,19 @@ export async function handleRequest(req: Request) {
97
98
  // itself, so that was a second full path resolution of the same string on
98
99
  // every request.
99
100
  const config = Bakery.config
101
+
102
+ // Before anything else, including the forbidden-path check: a preflight names
103
+ // the route it is asking about in a *header*, not the path, so running it
104
+ // through routing would answer a question nobody asked. The browser sends no
105
+ // credentials with it either, which is why it cannot be authorised.
106
+ //
107
+ // Only when `cors` is configured. Absent, this is not reached at all and the
108
+ // browser's own default applies.
109
+ if (config.cors) {
110
+ const preflight = preflightResponse(config.cors, req)
111
+ if (preflight) return preflight
112
+ }
113
+
100
114
  const serveRoot = config.root
101
115
  if (fs.isForbidden(serveRoot + path, serveRoot)) {
102
116
  return new Response('Forbidden', { status: 403 })
@@ -338,6 +352,14 @@ export async function processResponse(
338
352
  // append, not set: a handler may already have issued its own Set-Cookie
339
353
  // (e.g. an auth cookie from a login route) that must not be overwritten.
340
354
  sess && resp.headers.append('Set-Cookie', sess)
355
+
356
+ // Every response funnels through here — pages, API JSON, static files, error
357
+ // pages — so this is the one place that cannot miss one. Applied before ETag
358
+ // so the negotiated `Vary` sees the `Origin` entry and merges with it rather
359
+ // than either overwriting the other.
360
+ const cors = Bakery.config.cors
361
+ if (cors) applyCors(cors, req, resp)
362
+
341
363
  const final = ETag.sendResponse(req, resp)
342
364
  if (!(final instanceof Response)) {
343
365
  log({
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Cross-origin resource sharing.
3
+ *
4
+ * Bakery already ships the pieces of an API server — `ApiHandler`, sessions,
5
+ * CSRF, rate limiting — and had no way to serve one to a browser on another
6
+ * origin. This is that.
7
+ *
8
+ * Two halves, and both are needed: a preflight `OPTIONS` has to be answered
9
+ * before routing (there is no route to run, and the browser will not send the
10
+ * real request until it is answered), and every *other* response needs the
11
+ * headers appended on the way out.
12
+ *
13
+ * **Nothing happens unless `cors` is configured.** No default origin, not even
14
+ * a permissive one in development: a framework that quietly allows every origin
15
+ * teaches people it works and then surprises them in production. An absent
16
+ * config means the headers are never written, which is the browser's own
17
+ * default and the safe one.
18
+ */
19
+
20
+ /** What the app writes in `server.config.ts`. */
21
+ export interface CorsOptions {
22
+ /**
23
+ * Origins allowed to read responses.
24
+ *
25
+ * `'*'` is honoured literally, and is refused in combination with
26
+ * `credentials` — see `resolveOrigin`. A function receives the request's
27
+ * `Origin` and returns the value to echo, or `null` to deny.
28
+ */
29
+ origin: string | string[] | ((origin: string) => string | null)
30
+ /** Defaults to the methods a browser will preflight for. */
31
+ methods?: string[]
32
+ /** Request headers the browser may send. Defaults to echoing what it asks. */
33
+ allowHeaders?: string[]
34
+ /** Response headers JavaScript may read. Nothing is exposed by default. */
35
+ exposeHeaders?: string[]
36
+ /** Send `Access-Control-Allow-Credentials`. Incompatible with `origin: '*'`. */
37
+ credentials?: boolean
38
+ /** Preflight cache lifetime in seconds. */
39
+ maxAge?: number
40
+ }
41
+
42
+ const DEFAULT_METHODS = ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE']
43
+
44
+ /**
45
+ * The value for `Access-Control-Allow-Origin`, or `null` to send nothing.
46
+ *
47
+ * **`'*'` with credentials is refused rather than silently downgraded.** The
48
+ * browser rejects that pairing anyway, so honouring it would produce a request
49
+ * that fails in the client with a CORS error and a server that believes it
50
+ * allowed the call. Echoing the origin instead would be a *quiet widening* of
51
+ * what the app asked for. Returning null makes the misconfiguration visible as
52
+ * a denied request, which is the direction a security control should fail in.
53
+ */
54
+ export function resolveOrigin(
55
+ options: CorsOptions,
56
+ requestOrigin: string | null,
57
+ ): string | null {
58
+ const { origin, credentials } = options
59
+
60
+ if (origin === '*') return credentials ? null : '*'
61
+ if (!requestOrigin) return null
62
+
63
+ if (typeof origin === 'function') return origin(requestOrigin)
64
+ if (Array.isArray(origin)) {
65
+ return origin.includes(requestOrigin) ? requestOrigin : null
66
+ }
67
+ return origin === requestOrigin ? requestOrigin : null
68
+ }
69
+
70
+ /**
71
+ * Headers for a non-preflight response, or `null` when the origin is denied.
72
+ *
73
+ * `Vary: Origin` is always set when the allowed origin is anything but `'*'`,
74
+ * because the response now differs per origin and a shared cache that ignored
75
+ * that would serve one origin's response to another.
76
+ */
77
+ export function corsHeaders(
78
+ options: CorsOptions,
79
+ requestOrigin: string | null,
80
+ ): Record<string, string> | null {
81
+ const allowed = resolveOrigin(options, requestOrigin)
82
+ if (allowed === null) return null
83
+
84
+ const headers: Record<string, string> = {
85
+ 'Access-Control-Allow-Origin': allowed,
86
+ }
87
+ if (allowed !== '*') headers.Vary = 'Origin'
88
+ if (options.credentials) {
89
+ headers['Access-Control-Allow-Credentials'] = 'true'
90
+ }
91
+ if (options.exposeHeaders?.length) {
92
+ headers['Access-Control-Expose-Headers'] = options.exposeHeaders.join(', ')
93
+ }
94
+ return headers
95
+ }
96
+
97
+ /**
98
+ * The response to a preflight, or `null` if this is not one.
99
+ *
100
+ * A preflight is `OPTIONS` *with* `Access-Control-Request-Method` — plain
101
+ * `OPTIONS` is a normal request and must fall through to routing, or an app
102
+ * with its own OPTIONS route would find it shadowed.
103
+ *
104
+ * 204 rather than 200: there is no body, and some proxies treat a 200 with no
105
+ * content-length as needing one.
106
+ */
107
+ export function preflightResponse(
108
+ options: CorsOptions,
109
+ req: Request,
110
+ ): Response | null {
111
+ if (req.method !== 'OPTIONS') return null
112
+ if (!req.headers.get('Access-Control-Request-Method')) return null
113
+
114
+ const base = corsHeaders(options, req.headers.get('Origin'))
115
+ // A denied origin still gets an answer, just without the headers that would
116
+ // permit the call. Returning 403 here would be a worse signal: the browser
117
+ // reports a CORS failure either way, and a 403 invites debugging the route.
118
+ if (!base) return new Response(null, { status: 204 })
119
+
120
+ const headers: Record<string, string> = {
121
+ ...base,
122
+ 'Access-Control-Allow-Methods': (options.methods ?? DEFAULT_METHODS).join(
123
+ ', ',
124
+ ),
125
+ }
126
+
127
+ // Echoing the requested headers is what makes a default config usable:
128
+ // enumerating every header a client might send is a list nobody maintains,
129
+ // and getting it wrong fails at request time in the browser only.
130
+ const requested = req.headers.get('Access-Control-Request-Headers')
131
+ const allow = options.allowHeaders?.length
132
+ ? options.allowHeaders.join(', ')
133
+ : requested
134
+ if (allow) headers['Access-Control-Allow-Headers'] = allow
135
+
136
+ if (options.maxAge !== undefined) {
137
+ headers['Access-Control-Max-Age'] = String(options.maxAge)
138
+ }
139
+
140
+ // Vary on the negotiated request headers too, for the same caching reason.
141
+ headers.Vary = [headers.Vary, 'Access-Control-Request-Headers']
142
+ .filter(Boolean)
143
+ .join(', ')
144
+
145
+ return new Response(null, { status: 204, headers })
146
+ }
147
+
148
+ /** Append the headers to a response that already exists. */
149
+ export function applyCors(
150
+ options: CorsOptions,
151
+ req: Request,
152
+ res: Response,
153
+ ): Response {
154
+ const headers = corsHeaders(options, req.headers.get('Origin'))
155
+ if (!headers) return res
156
+
157
+ for (const [key, value] of Object.entries(headers)) {
158
+ // `Vary` may already carry a value from ETag negotiation; appending keeps
159
+ // both rather than dropping whichever ran second.
160
+ if (key === 'Vary' && res.headers.has('Vary')) {
161
+ const existing = res.headers.get('Vary') ?? ''
162
+ if (!existing.split(',').some(v => v.trim() === value)) {
163
+ res.headers.set('Vary', `${existing}, ${value}`)
164
+ }
165
+ continue
166
+ }
167
+ res.headers.set(key, value)
168
+ }
169
+ return res
170
+ }
@@ -6,3 +6,4 @@ export * from './etag'
6
6
  export * from './html'
7
7
  export * from './ip'
8
8
  export * from './response'
9
+ export * from './sse'
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Server-sent events.
3
+ *
4
+ * Bakery had WebSockets and nothing for one-way streaming, which is the cheaper
5
+ * half of that pair and the right shape for progress, notifications and tailing
6
+ * — no upgrade, no protocol, reconnects handled by the browser.
7
+ *
8
+ * A route could always return a `Response` wrapping a `ReadableStream` and it
9
+ * would reach the client intact: `ETag.sendResponse` returns early without an
10
+ * `ETag` header, and `injectIfHtml` ignores anything that is not HTML. What was
11
+ * missing is the framing, and framing is where this goes wrong — a payload
12
+ * containing a newline silently truncates unless every line is prefixed, and a
13
+ * write after the client has gone throws where nobody is catching.
14
+ */
15
+
16
+ /** One event. Every field is optional except the payload. */
17
+ export interface SSEMessage {
18
+ /** Serialised with `JSON.stringify` unless it is already a string. */
19
+ data: unknown
20
+ /** `event:` — the client listens for this name instead of `message`. */
21
+ event?: string
22
+ /** `id:` — echoed back as `Last-Event-ID` when the browser reconnects. */
23
+ id?: string
24
+ /** `retry:` — how long the browser waits before reconnecting, in ms. */
25
+ retry?: number
26
+ }
27
+
28
+ /** The handle a producer writes to. */
29
+ export interface SSEStream {
30
+ /** Send one event. A no-op once the stream is closed. */
31
+ send(message: SSEMessage | unknown): void
32
+ /** Send a `:` comment. Useful as a keep-alive through idle proxies. */
33
+ comment(text?: string): void
34
+ /** Close the stream. Idempotent. */
35
+ close(): void
36
+ /** True once the client has gone or `close()` has run. */
37
+ readonly closed: boolean
38
+ }
39
+
40
+ export interface SSEOptions {
41
+ /**
42
+ * Milliseconds between automatic keep-alive comments. `0` disables them.
43
+ *
44
+ * Defaults to 15s because idle proxies and load balancers commonly cut a
45
+ * connection at 30–60s, and a dead SSE stream is invisible: the browser
46
+ * reconnects silently, so the symptom is duplicated work on the server rather
47
+ * than an error anyone sees.
48
+ */
49
+ keepAlive?: number
50
+ /** Initial `retry:` hint sent once, before any event. */
51
+ retry?: number
52
+ }
53
+
54
+ /**
55
+ * Encode one message as an SSE frame.
56
+ *
57
+ * **Every line of `data` is prefixed.** A payload containing a newline is
58
+ * otherwise cut short at that newline — the rest is read as a new field, and
59
+ * the client sees a truncated message rather than an error. Pretty-printed JSON
60
+ * and stack traces both hit this.
61
+ */
62
+ export function encodeSSE(message: SSEMessage): string {
63
+ const lines: string[] = []
64
+
65
+ if (message.event) lines.push(`event: ${message.event}`)
66
+ if (message.id !== undefined) lines.push(`id: ${message.id}`)
67
+ if (message.retry !== undefined) lines.push(`retry: ${message.retry}`)
68
+
69
+ const payload =
70
+ typeof message.data === 'string'
71
+ ? message.data
72
+ : JSON.stringify(message.data)
73
+
74
+ // `?? ''` rather than skipping: `JSON.stringify(undefined)` is undefined, and
75
+ // a frame with no `data:` line at all is a comment to the client — the event
76
+ // would vanish rather than arrive empty.
77
+ for (const line of (payload ?? '').split('\n')) lines.push(`data: ${line}`)
78
+
79
+ // The blank line terminates the frame. Without it the client buffers forever.
80
+ return `${lines.join('\n')}\n\n`
81
+ }
82
+
83
+ /**
84
+ * Build an SSE response and hand the producer a stream to write to.
85
+ *
86
+ * export default defineRoute(req =>
87
+ * sse(req, stream => {
88
+ * const timer = setInterval(() => stream.send({ now: Date.now() }), 1000)
89
+ * return () => clearInterval(timer)
90
+ * }),
91
+ * )
92
+ *
93
+ * The producer may return a cleanup function, which runs exactly once when the
94
+ * client disconnects or `close()` is called. That is the only reliable place to
95
+ * stop a timer or unsubscribe: without it, a closed connection leaves the
96
+ * interval running for the life of the process, and the leak is silent.
97
+ */
98
+ export function sse(
99
+ req: Request,
100
+ producer: (
101
+ stream: SSEStream,
102
+ ) => void | (() => void) | Promise<void | (() => void)>,
103
+ options: SSEOptions = {},
104
+ ): Response {
105
+ const encoder = new TextEncoder()
106
+ const { keepAlive = 15_000, retry } = options
107
+
108
+ let controller: ReadableStreamDefaultController<Uint8Array> | null = null
109
+ let closed = false
110
+ let cleanup: (() => void) | void
111
+ let keepAliveTimer: ReturnType<typeof setInterval> | undefined
112
+
113
+ const write = (chunk: string) => {
114
+ if (closed || !controller) return
115
+ try {
116
+ controller.enqueue(encoder.encode(chunk))
117
+ } catch {
118
+ // The client went away between the `closed` check and the enqueue. That
119
+ // is a normal race on every disconnect, not an error worth surfacing —
120
+ // and throwing here would reject inside a timer callback, where nothing
121
+ // is catching.
122
+ finish()
123
+ }
124
+ }
125
+
126
+ function finish() {
127
+ if (closed) return
128
+ closed = true
129
+ if (keepAliveTimer) clearInterval(keepAliveTimer)
130
+ try {
131
+ cleanup?.()
132
+ } catch {
133
+ // A producer's cleanup that throws must not prevent the stream closing;
134
+ // the connection is already going away either way.
135
+ }
136
+ try {
137
+ controller?.close()
138
+ } catch {
139
+ // Already closed by the runtime when the client disconnected.
140
+ }
141
+ }
142
+
143
+ const stream: SSEStream = {
144
+ send(message) {
145
+ const normalised: SSEMessage =
146
+ message && typeof message === 'object' && 'data' in (message as object)
147
+ ? (message as SSEMessage)
148
+ : { data: message }
149
+ write(encodeSSE(normalised))
150
+ },
151
+ comment(text = '') {
152
+ write(`: ${text}\n\n`)
153
+ },
154
+ close: finish,
155
+ get closed() {
156
+ return closed
157
+ },
158
+ }
159
+
160
+ const body = new ReadableStream<Uint8Array>({
161
+ async start(c) {
162
+ controller = c
163
+
164
+ // The client aborting is the common ending, not an exception. Without
165
+ // this the producer keeps writing into a dead socket.
166
+ req.signal?.addEventListener('abort', finish, { once: true })
167
+ if (req.signal?.aborted) return finish()
168
+
169
+ if (retry !== undefined) write(`retry: ${retry}\n\n`)
170
+ if (keepAlive > 0) {
171
+ keepAliveTimer = setInterval(
172
+ () => stream.comment('keep-alive'),
173
+ keepAlive,
174
+ )
175
+ }
176
+
177
+ try {
178
+ cleanup = await producer(stream)
179
+ } catch {
180
+ // A producer that throws ends the stream rather than leaving the client
181
+ // hanging on a connection nobody will write to again.
182
+ finish()
183
+ }
184
+ },
185
+ cancel: finish,
186
+ })
187
+
188
+ return new Response(body, {
189
+ headers: {
190
+ 'Content-Type': 'text/event-stream; charset=utf-8',
191
+ // A cached event stream is a stream that never arrives.
192
+ 'Cache-Control': 'no-cache, no-transform',
193
+ Connection: 'keep-alive',
194
+ // nginx buffers proxied responses by default, which holds every event
195
+ // until the buffer fills — the stream appears to work in development and
196
+ // to hang in production. This is the documented opt-out.
197
+ 'X-Accel-Buffering': 'no',
198
+ },
199
+ })
200
+ }