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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +6 -0
- data/README.md +37 -0
- data/bin/cloudflare-workers-build +255 -0
- data/docs/ARCHITECTURE.md +59 -0
- data/exe/auto-await +102 -0
- data/exe/compile-assets +142 -0
- data/exe/compile-erb +262 -0
- data/lib/cloudflare_workers/ai.rb +197 -0
- data/lib/cloudflare_workers/async_registry.rb +198 -0
- data/lib/cloudflare_workers/auto_await/analyzer.rb +264 -0
- data/lib/cloudflare_workers/auto_await/transformer.rb +19 -0
- data/lib/cloudflare_workers/cache.rb +234 -0
- data/lib/cloudflare_workers/durable_object.rb +590 -0
- data/lib/cloudflare_workers/email.rb +180 -0
- data/lib/cloudflare_workers/http.rb +164 -0
- data/lib/cloudflare_workers/multipart.rb +332 -0
- data/lib/cloudflare_workers/queue.rb +407 -0
- data/lib/cloudflare_workers/scheduled.rb +185 -0
- data/lib/cloudflare_workers/stream.rb +317 -0
- data/lib/cloudflare_workers/version.rb +5 -0
- data/lib/cloudflare_workers.rb +801 -0
- data/lib/opal_patches.rb +653 -0
- data/runtime/patch-opal-evals.mjs +66 -0
- data/runtime/setup-node-crypto.mjs +20 -0
- data/runtime/worker.mjs +9 -0
- data/runtime/worker_module.mjs +384 -0
- data/runtime/wrangler.toml.example +40 -0
- data/templates/wrangler.toml.example +8 -0
- metadata +104 -0
|
@@ -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
|
+
|