homura-runtime 0.1.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.
@@ -0,0 +1,317 @@
1
+ # frozen_string_literal: true
2
+ # backtick_javascript: true
3
+ # await: true
4
+ #
5
+ # Phase 11A — Server-Sent Events / streaming response.
6
+ #
7
+ # Cloudflare Workers speaks streaming via `new Response(readableStream)`.
8
+ # The cleanest way to bridge Ruby → streaming JS is a TransformStream:
9
+ # Ruby writes to the `writable` side; the runtime flushes the `readable`
10
+ # side to the client incrementally.
11
+ #
12
+ # Public API:
13
+ #
14
+ # class App < Sinatra::Base
15
+ # register Sinatra::Streaming
16
+ #
17
+ # get '/demo/sse' do
18
+ # sse do |out|
19
+ # 5.times do |i|
20
+ # out << "data: tick #{i} — #{Time.now.iso8601}\n\n"
21
+ # out.sleep(1)
22
+ # end
23
+ # end
24
+ # end
25
+ # end
26
+ #
27
+ # The block runs in a JS async task attached to `ctx.waitUntil`, so the
28
+ # Workers isolate stays alive until the stream closes. All writes inside
29
+ # the block compile to `await writer.write(encoder.encode(...))`, so
30
+ # back-pressure is preserved across the boundary.
31
+
32
+ module Cloudflare
33
+ # Stream returned from a Sinatra route. `build_js_response` checks
34
+ # `#sse_stream?` and hands the JS readable stream straight to Workers'
35
+ # `new Response(readable, { headers })`.
36
+ class SSEStream
37
+ DEFAULT_HEADERS = {
38
+ 'content-type' => 'text/event-stream; charset=utf-8',
39
+ 'cache-control' => 'no-cache, no-transform',
40
+ 'x-accel-buffering' => 'no',
41
+ 'connection' => 'keep-alive'
42
+ }.freeze
43
+
44
+ def initialize(headers: nil, ctx: nil, &block)
45
+ @block = block
46
+ @ctx = ctx
47
+ @extra_headers = headers || {}
48
+ @js_stream = nil
49
+ end
50
+
51
+ # Duck-typed marker consumed by Rack::Handler::CloudflareWorkers.
52
+ def sse_stream?
53
+ true
54
+ end
55
+
56
+ # Merged header set to emit on the Response.
57
+ def response_headers
58
+ DEFAULT_HEADERS.merge(@extra_headers)
59
+ end
60
+
61
+ # Build the JS ReadableStream lazily — build_js_response calls
62
+ # `.js_stream`. After this point the async task is running; we can
63
+ # only do it once per stream.
64
+ def js_stream
65
+ return @js_stream if @js_stream
66
+ blk = @block
67
+ ctx = @ctx
68
+ raise ArgumentError, 'SSEStream needs a block' if blk.nil?
69
+
70
+ ts = `new TransformStream()`
71
+ writer = `#{ts}.writable.getWriter()`
72
+ out = SSEOut.new(writer)
73
+
74
+ # Kick off the user block in an async task. run_stream compiles
75
+ # to a JS async function (this file is `# await: true`), so
76
+ # calling it returns a Promise; we bind it to ctx.waitUntil so
77
+ # the Workers runtime doesn't tear the isolate down before the
78
+ # stream finishes.
79
+ promise = run_stream(out, blk)
80
+ `#{ctx}.waitUntil(#{promise})` if ctx
81
+
82
+ @js_stream = `#{ts}.readable`
83
+ end
84
+
85
+ # Rack body contract. Iterating is a no-op — the actual bytes flow
86
+ # through the JS pipe. Sinatra's content-length calculator therefore
87
+ # sees no body and leaves the header out, which is exactly right for
88
+ # a chunked streaming response.
89
+ def each; end
90
+ def close; end
91
+
92
+ private
93
+
94
+ def run_stream(out, blk)
95
+ begin
96
+ result = blk.call(out)
97
+ # The block may or may not be async. If the block contains any
98
+ # `__await__` / `out.sleep`, Opal compiles it to async and
99
+ # `.call` returns a JS Promise — await it. Otherwise the guard
100
+ # falls through harmlessly. The trailing `.__await__` also
101
+ # forces Opal to mark `run_stream` itself as async — without it
102
+ # the `ensure` branch below would run synchronously BEFORE the
103
+ # awaited block actually finishes and the writer would close
104
+ # after the very first chunk was flushed (bug observed in
105
+ # Phase 11A: /demo/sse sent only `tick 0` then hung up).
106
+ await_if_promise(result).__await__
107
+ rescue ::Exception => e
108
+ begin
109
+ trace = `#{e}.$backtrace ? #{e}.$backtrace().join("\\n") : ''`
110
+ $stderr.puts("[sse] #{e.class}: #{e.message} #{trace}")
111
+ rescue StandardError
112
+ # best-effort; never let logging fail the cleanup branch
113
+ end
114
+ ensure
115
+ # `.__await__` here too, for the same reason: the ensure clause
116
+ # must not return until the writer is actually closed (otherwise
117
+ # the final chunks + `event: close` would race the Response).
118
+ out.close.__await__
119
+ end
120
+ end
121
+
122
+ # Await only when the value is thenable; otherwise return as-is.
123
+ # Detection is a JS-side typeof probe — Ruby's `Object#then` (alias
124
+ # of `yield_self` since 2.6) would falsely trigger on ordinary
125
+ # Hashes / Arrays if we used `#respond_to?(:then)`.
126
+ def await_if_promise(val)
127
+ thenable = `(#{val} != null && typeof #{val}.then === 'function')`
128
+ return val unless thenable
129
+ val.__await__
130
+ end
131
+ end
132
+
133
+ # Writer side of the SSE pipe. Passed to the user block as `out`.
134
+ # Every write goes through a `TextEncoder` so the bytes hit the
135
+ # client as valid UTF-8 regardless of the Opal String's internal
136
+ # representation.
137
+ #
138
+ # Writes are fire-and-forget: the WritableStream internally queues
139
+ # them in order, so a sequence of `out << a; out << b` always lands
140
+ # on the wire as "ab" without the caller having to manually `.__await__`
141
+ # each write. `close` waits on the tail promise in the chain so it
142
+ # doesn't close the writer mid-queue (which would truncate bytes the
143
+ # client had not yet drained).
144
+ #
145
+ # Memory is O(1) — we chain promises instead of accumulating them in
146
+ # an ever-growing array. A long-lived / high-frequency SSE endpoint
147
+ # therefore doesn't leak once-flushed write-ack promises.
148
+ class SSEOut
149
+ def initialize(writer)
150
+ @writer = writer
151
+ @encoder = `new TextEncoder()`
152
+ # `@tail` tracks only the *latest* pending writer.write()
153
+ # promise. Each write() chains onto it via .then(), so the
154
+ # chain length stays 1 regardless of how many writes have
155
+ # already flushed.
156
+ @tail = `Promise.resolve()`
157
+ @closed = false
158
+ end
159
+
160
+ # Write a raw string chunk to the stream. The caller is responsible
161
+ # for SSE framing (e.g. `"data: foo\n\n"`). Returns self.
162
+ def write(data)
163
+ return self if @closed
164
+ s = data.to_s
165
+ w = @writer
166
+ enc = @encoder
167
+ # Chain the write onto the current tail so ordering is still
168
+ # enforced but the promise graph stays flat (Copilot review #3 —
169
+ # prior implementation pushed every write into an unbounded
170
+ # array which would leak memory on long-running streams).
171
+ @tail = `#{@tail}.then(function() { return #{w}.write(#{enc}.encode(#{s})); })`
172
+ self
173
+ end
174
+ alias_method :<<, :write
175
+
176
+ # Helper: emit a well-formed SSE event. `data` is split on LF and
177
+ # each line prefixed with `data:` per the SSE spec.
178
+ def event(data, event: nil, id: nil, retry_ms: nil)
179
+ buf = ''
180
+ buf += "event: #{event}\n" if event
181
+ buf += "id: #{id}\n" if id
182
+ buf += "retry: #{retry_ms.to_i}\n" if retry_ms
183
+ data.to_s.split("\n", -1).each { |line| buf += "data: #{line}\n" }
184
+ buf += "\n"
185
+ write(buf)
186
+ end
187
+
188
+ # `: keep-alive` comments are the SSE-standard keep-alive mechanism.
189
+ # Browsers / proxies won't drop the connection while they see one.
190
+ def comment(text)
191
+ write(": #{text}\n\n")
192
+ end
193
+
194
+ # Suspend the task for `seconds` seconds (Float allowed). Uses
195
+ # setTimeout under the hood so the Workers CPU budget is not
196
+ # charged for wall-clock waiting.
197
+ def sleep(seconds)
198
+ ms = (seconds.to_f * 1000).to_i
199
+ `(new Promise(function(r){ setTimeout(r, #{ms}); }))`.__await__
200
+ self
201
+ end
202
+
203
+ # Close the writable side. Waits for the tail promise in the write
204
+ # chain to drain so the client receives the final bytes before
205
+ # `done: true`. After this, subsequent writes become no-ops so a
206
+ # racing producer doesn't crash on a closed writer.
207
+ def close
208
+ return self if @closed
209
+ @closed = true
210
+ w = @writer
211
+ tail = @tail
212
+ # writer.close() itself can reject if the consumer bailed out.
213
+ # Swallow — the Workers runtime already surfaces the underlying
214
+ # error to the client via the HTTP layer. Single-line x-string
215
+ # so Opal emits it as an expression (see Multipart#to_uint8_array
216
+ # for the same gotcha).
217
+ `(async function(t, wr){ try { await t; } catch(e) {} try { await wr.close(); } catch(e) {} })(#{tail}, #{w})`.__await__
218
+ self
219
+ end
220
+
221
+ def closed?
222
+ @closed
223
+ end
224
+
225
+ # Rack-body compatibility so the SSEOut itself is also iterable by
226
+ # `build_js_response` when used as a body (unusual but supported).
227
+ def each; end
228
+ end
229
+ end
230
+
231
+ # --------------------------------------------------------------------
232
+ # Sinatra DSL helper — `sse do |out| ... end`
233
+ # --------------------------------------------------------------------
234
+
235
+ module Sinatra
236
+ module Streaming
237
+ # Emit a Server-Sent Events response. The block receives a writer
238
+ # (`out`) that accepts `<<` (raw string) and `event(data, event:, id:)`
239
+ # for framed events. The block runs in an async task attached to
240
+ # `ctx.waitUntil`, so the isolate stays alive until the block ends.
241
+ def sse(headers: nil, &block)
242
+ ctx = env['cloudflare.ctx']
243
+ ::Cloudflare::SSEStream.new(headers: headers, ctx: ctx, &block)
244
+ end
245
+
246
+ # Sinatra本家の `stream do |out| ... end` 互換 DSL。
247
+ # 本家の `Sinatra::Helpers#stream` は Rack EM/Thin で動くスケジューラを
248
+ # 前提にするが、Workers では EventMachine も Thread も無いので
249
+ # 代わりに Cloudflare の `ReadableStream` パスを使う。
250
+ #
251
+ # Call sites look identical to upstream Sinatra:
252
+ #
253
+ # get '/numbers' do
254
+ # stream do |out|
255
+ # 10.times do |i|
256
+ # out << "chunk #{i}\n"
257
+ # out.sleep(0.1).__await__
258
+ # end
259
+ # end
260
+ # end
261
+ #
262
+ # SSE 用に `stream(type: :sse) do |out| ... end` もサポート。type の
263
+ # デフォルトは `:plain` (text/plain) で、SSE-like ヘッダは付かない。
264
+ # :sse / :event_stream を指定すると Cloudflare::SSEStream::DEFAULT_HEADERS
265
+ # がマージされる(`sse do |out|` と同じ挙動)。
266
+ #
267
+ # `keep_open:` は本家互換だが Workers では意味がない(EM 前提)ため
268
+ # 受け取るだけで使わない。
269
+ def stream(keep_open: false, type: :plain, headers: nil, &block)
270
+ ctx = env['cloudflare.ctx']
271
+ extra_headers = headers || {}
272
+ merged = case type
273
+ when :sse, :event_stream
274
+ extra_headers # SSE defaults は SSEStream 側で入る
275
+ else
276
+ # Plain streaming — start from an empty default set so
277
+ # the SSE headers don't get force-injected into e.g. a
278
+ # log-tailing or chunked-JSON endpoint.
279
+ { 'content-type' => 'text/plain; charset=utf-8' }.merge(extra_headers)
280
+ end
281
+ ::Cloudflare::SSEStream.new(headers: merged, ctx: ctx, &block)
282
+ end
283
+
284
+ # Register the helper on a Sinatra app. Use `register Sinatra::Streaming`.
285
+ def self.registered(app)
286
+ app.helpers Streaming
287
+ end
288
+ end
289
+ end
290
+
291
+ # --------------------------------------------------------------------
292
+ # Override the stock `Sinatra::Base#stream` so upstream Sinatra apps
293
+ # that call it (without registering the helper module) also work on
294
+ # Workers. The upstream implementation (vendor/sinatra/base.rb:524)
295
+ # defers to EventMachine; Workers has no EM, so we re-route through
296
+ # the Cloudflare ReadableStream path. This is explicit: calling
297
+ # `register Sinatra::Streaming` still gets you the merging DSL above;
298
+ # bare `stream do |out| ... end` still works via this override.
299
+ # --------------------------------------------------------------------
300
+
301
+ module Sinatra
302
+ class Base
303
+ # Re-open to override. We keep the signature compatible with
304
+ # upstream (`stream(keep_open = false, &block)`) but internally
305
+ # route through Cloudflare::SSEStream. Upstream's `Stream` class
306
+ # is not used on Workers (no EventMachine / Thread pool).
307
+ def stream(keep_open = false, &block)
308
+ ctx = env['cloudflare.ctx']
309
+ ::Cloudflare::SSEStream.new(
310
+ headers: { 'content-type' => 'text/plain; charset=utf-8' },
311
+ ctx: ctx,
312
+ &block
313
+ )
314
+ end
315
+ end
316
+ end
317
+
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CloudflareWorkers
4
+ VERSION = '0.1.0'
5
+ end