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
data/exe/compile-erb
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
#
|
|
4
|
+
# homura ERB precompiler.
|
|
5
|
+
#
|
|
6
|
+
# Opal-on-Workers can't run ERB the normal way: ERB compiles its
|
|
7
|
+
# templates to Ruby source and runs them via `binding.eval`, which
|
|
8
|
+
# lands on `new Function($code)`, which Cloudflare Workers bans with
|
|
9
|
+
# "Code generation from strings disallowed for this context".
|
|
10
|
+
#
|
|
11
|
+
# The workaround is to compile each ERB template to a *plain Ruby
|
|
12
|
+
# method* ahead of time (in CRuby, at build time), then feed that
|
|
13
|
+
# generated Ruby source into Opal alongside everything else. The
|
|
14
|
+
# runtime cost is a single Proc lookup plus a couple of string
|
|
15
|
+
# concatenations per tag; no eval, no new Function, no surprises.
|
|
16
|
+
#
|
|
17
|
+
# Usage:
|
|
18
|
+
# bin/compile-erb --input views --output build/homura_templates.rb --namespace HomuraTemplates
|
|
19
|
+
# or (legacy):
|
|
20
|
+
# bin/compile-erb views/foo.erb # writes compiled templates + Sinatra hook to stdout
|
|
21
|
+
#
|
|
22
|
+
# The output registers each template with `HomuraTemplates` and
|
|
23
|
+
# monkey-patches `Sinatra::Templates#erb` to dispatch there.
|
|
24
|
+
|
|
25
|
+
require 'fileutils'
|
|
26
|
+
require 'optparse'
|
|
27
|
+
|
|
28
|
+
HELP = <<~USAGE
|
|
29
|
+
Usage:
|
|
30
|
+
bin/compile-erb [--input DIR] [--output FILE] [--namespace ModuleName]
|
|
31
|
+
[--stdout] [--] [template.erb ...]
|
|
32
|
+
|
|
33
|
+
Defaults (Phase 15-A homura):
|
|
34
|
+
--input views --output build/homura_templates.rb --namespace HomuraTemplates
|
|
35
|
+
|
|
36
|
+
With positional .erb paths and no --output: print combined Ruby to stdout (legacy).
|
|
37
|
+
USAGE
|
|
38
|
+
|
|
39
|
+
# Very small ERB dialect tokenizer. ERB is simple enough to parse by
|
|
40
|
+
# hand and we specifically DO NOT want to pull in stdlib ERB — its
|
|
41
|
+
# generated source uses `String#<<` mutation, which Opal's immutable
|
|
42
|
+
# Strings reject at runtime.
|
|
43
|
+
module HomuraERB
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
# Compile an ERB source string to a Ruby method body that assembles
|
|
47
|
+
# an HTML string in a local variable `_out`. The body references
|
|
48
|
+
# `@ivars` and method calls directly, so it must be run via
|
|
49
|
+
# `instance_exec` on the Sinatra instance the route was dispatched
|
|
50
|
+
# on. That gives `<%= @name %>` the usual Sinatra semantics.
|
|
51
|
+
def compile(source)
|
|
52
|
+
ruby = +"_out = ''\n"
|
|
53
|
+
cursor = 0
|
|
54
|
+
while cursor < source.length
|
|
55
|
+
open_idx = source.index('<%', cursor)
|
|
56
|
+
if open_idx.nil?
|
|
57
|
+
static = source[cursor..]
|
|
58
|
+
ruby << "_out = _out + #{static.inspect}\n" unless static.empty?
|
|
59
|
+
break
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Static text before the next `<%`
|
|
63
|
+
if open_idx > cursor
|
|
64
|
+
static = source[cursor...open_idx]
|
|
65
|
+
ruby << "_out = _out + #{static.inspect}\n"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
close_idx = source.index('%>', open_idx + 2)
|
|
69
|
+
raise "Unmatched `<%` in ERB source (starting at #{open_idx})" if close_idx.nil?
|
|
70
|
+
|
|
71
|
+
inner = source[(open_idx + 2)...close_idx]
|
|
72
|
+
|
|
73
|
+
if inner.start_with?('#')
|
|
74
|
+
# `<%# comment %>` — drop
|
|
75
|
+
elsif inner.start_with?('==')
|
|
76
|
+
# `<%== expression %>` — identical to `<%= %>` in this minimal
|
|
77
|
+
# dialect (no HTML escaping yet; the author is responsible).
|
|
78
|
+
expr = inner[2..].strip
|
|
79
|
+
ruby << "_out = _out + ((#{expr})).to_s\n"
|
|
80
|
+
elsif inner.start_with?('=')
|
|
81
|
+
# `<%= expression %>`
|
|
82
|
+
expr = inner[1..].strip
|
|
83
|
+
ruby << "_out = _out + ((#{expr})).to_s\n"
|
|
84
|
+
else
|
|
85
|
+
# `<% code %>` — Ruby statement(s), emitted verbatim
|
|
86
|
+
ruby << inner.strip << "\n"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
cursor = close_idx + 2
|
|
90
|
+
end
|
|
91
|
+
ruby << "_out\n"
|
|
92
|
+
ruby
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Wrap the compiled body in a `#{namespace}.register` call.
|
|
96
|
+
def wrap(template_name, compiled_body, namespace)
|
|
97
|
+
indented = compiled_body.each_line.map { |l| " #{l}" }.join
|
|
98
|
+
<<~RUBY
|
|
99
|
+
#{namespace}.register(:#{template_name}) do |locals = {}|
|
|
100
|
+
#{indented.chomp}
|
|
101
|
+
end
|
|
102
|
+
RUBY
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# Driver
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def default_inputs_for(dir)
|
|
111
|
+
Dir.glob(File.join(dir, '*.erb')).sort
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def template_name_for(path)
|
|
115
|
+
File.basename(path, '.erb').to_sym
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def emit_header(io, namespace)
|
|
119
|
+
io.puts <<~RUBY
|
|
120
|
+
# frozen_string_literal: true
|
|
121
|
+
#
|
|
122
|
+
# Auto-generated by bin/compile-erb — DO NOT EDIT BY HAND.
|
|
123
|
+
#
|
|
124
|
+
# Run `bin/compile-erb` after modifying anything under views/ to
|
|
125
|
+
# regenerate. The Opal build command picks this file up via
|
|
126
|
+
# `-I build -r homura_templates` (or your chosen `--output` basename).
|
|
127
|
+
#
|
|
128
|
+
# Each registered template is a Proc that returns an HTML String.
|
|
129
|
+
# The Proc is `instance_exec`'d on the current Sinatra instance so
|
|
130
|
+
# `@ivars`, `params`, `request`, and any Sinatra helper remain
|
|
131
|
+
# accessible exactly as they would in a stock Sinatra route.
|
|
132
|
+
#
|
|
133
|
+
# Rendering is wired to `Sinatra::Templates#erb` further down so
|
|
134
|
+
# `erb :index` in user code dispatches here transparently. No
|
|
135
|
+
# runtime eval, no `new Function`, no Workers sandbox complaints.
|
|
136
|
+
|
|
137
|
+
module #{namespace}
|
|
138
|
+
@templates = {}
|
|
139
|
+
|
|
140
|
+
class << self
|
|
141
|
+
def register(name, &body)
|
|
142
|
+
@templates[name.to_sym] = body
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def render(name, instance, locals = {})
|
|
146
|
+
body = @templates[name.to_sym]
|
|
147
|
+
raise "#{namespace}: no template registered for \#{name.inspect}" unless body
|
|
148
|
+
instance.instance_exec(locals, &body)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def registered?(name)
|
|
152
|
+
@templates.key?(name.to_sym)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def names
|
|
156
|
+
@templates.keys
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
RUBY
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def emit_templates(io, inputs, namespace)
|
|
164
|
+
inputs.each do |path|
|
|
165
|
+
name = template_name_for(path)
|
|
166
|
+
source = File.read(path)
|
|
167
|
+
compiled = HomuraERB.compile(source)
|
|
168
|
+
io.puts
|
|
169
|
+
io.puts "# ---- #{path} -> :#{name} --------------------------------------"
|
|
170
|
+
io.puts HomuraERB.wrap(name, compiled, namespace)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def emit_sinatra_patch(io, namespace)
|
|
175
|
+
io.puts <<~RUBY
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------------
|
|
178
|
+
# Sinatra hook: `erb :name` dispatches to #{namespace}.render.
|
|
179
|
+
# ---------------------------------------------------------------------
|
|
180
|
+
#
|
|
181
|
+
# This file is loaded by Opal via `-r homura_templates` before
|
|
182
|
+
# `app/hello.rb`, so Sinatra is not yet loaded at require time.
|
|
183
|
+
# Pull it in explicitly and reopen `Sinatra::Templates` to install
|
|
184
|
+
# our ERB dispatcher. User code's own `require 'sinatra/base'`
|
|
185
|
+
# later becomes a no-op (already cached).
|
|
186
|
+
require 'sinatra/base'
|
|
187
|
+
|
|
188
|
+
module ::Sinatra
|
|
189
|
+
module Templates
|
|
190
|
+
# homura patch: dispatch to precompiled templates when we
|
|
191
|
+
# have one. Unknown symbols raise a clear message instead of
|
|
192
|
+
# wandering into upstream Tilt, which would blow up on
|
|
193
|
+
# Workers with "Code generation from strings disallowed".
|
|
194
|
+
def erb(template, options = {}, locals = {}, &block)
|
|
195
|
+
if template.is_a?(::Symbol) && ::#{namespace}.registered?(template)
|
|
196
|
+
locals ||= {}
|
|
197
|
+
::#{namespace}.render(template, self, locals)
|
|
198
|
+
else
|
|
199
|
+
raise "homura: erb \#{template.inspect} not precompiled; run bin/compile-erb"
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
RUBY
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# Entry point
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
if ARGV.include?('-h') || ARGV.include?('--help')
|
|
212
|
+
puts HELP
|
|
213
|
+
exit 0
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
options = {
|
|
217
|
+
input_dir: 'views',
|
|
218
|
+
output: 'build/homura_templates.rb',
|
|
219
|
+
namespace: 'HomuraTemplates',
|
|
220
|
+
stdout: false
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
parser = OptionParser.new do |op|
|
|
224
|
+
op.banner = 'Usage: bin/compile-erb [options] [--] [files...]'
|
|
225
|
+
op.on('--input DIR', 'Directory containing *.erb templates') { |d| options[:input_dir] = d }
|
|
226
|
+
op.on('--output PATH', 'Write generated Ruby to PATH') { |p| options[:output] = p }
|
|
227
|
+
op.on('--namespace NAME', 'Ruby module name for the registry') { |n| options[:namespace] = n }
|
|
228
|
+
op.on('--stdout', 'Write generated Ruby to stdout') { options[:stdout] = true }
|
|
229
|
+
end
|
|
230
|
+
parser.parse!
|
|
231
|
+
|
|
232
|
+
positional = ARGV.dup
|
|
233
|
+
namespace = options[:namespace]
|
|
234
|
+
|
|
235
|
+
inputs =
|
|
236
|
+
if positional.any?
|
|
237
|
+
positional.flat_map { |a| File.directory?(a) ? Dir.glob(File.join(a, '*.erb')).sort : [a] }
|
|
238
|
+
else
|
|
239
|
+
default_inputs_for(options[:input_dir])
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
if inputs.empty?
|
|
243
|
+
warn "bin/compile-erb: no .erb files found (under #{options[:input_dir]}/ or from args)"
|
|
244
|
+
exit 1
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
write_file = !options[:stdout] && (positional.empty? || options[:output])
|
|
248
|
+
out_path = options[:output]
|
|
249
|
+
|
|
250
|
+
if write_file
|
|
251
|
+
FileUtils.mkdir_p(File.dirname(out_path))
|
|
252
|
+
File.open(out_path, 'w') do |io|
|
|
253
|
+
emit_header(io, namespace)
|
|
254
|
+
emit_templates(io, inputs, namespace)
|
|
255
|
+
emit_sinatra_patch(io, namespace)
|
|
256
|
+
end
|
|
257
|
+
warn "compile-erb: wrote #{out_path} (#{inputs.size} templates)"
|
|
258
|
+
else
|
|
259
|
+
emit_header($stdout, namespace)
|
|
260
|
+
emit_templates($stdout, inputs, namespace)
|
|
261
|
+
emit_sinatra_patch($stdout, namespace)
|
|
262
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# backtick_javascript: true
|
|
3
|
+
# await: true
|
|
4
|
+
#
|
|
5
|
+
# Phase 10 — Workers AI binding wrapper.
|
|
6
|
+
#
|
|
7
|
+
# `Cloudflare::AI.run(model, inputs, binding: env['cloudflare.AI'])`
|
|
8
|
+
# wraps `env.AI.run(model, inputs, options)` and returns a Ruby Hash so
|
|
9
|
+
# Sinatra routes can call:
|
|
10
|
+
#
|
|
11
|
+
# ai = env['cloudflare.AI']
|
|
12
|
+
# out = Cloudflare::AI.run(
|
|
13
|
+
# '@cf/google/gemma-4-26b-a4b-it',
|
|
14
|
+
# { messages: [
|
|
15
|
+
# { role: 'system', content: 'You are a helpful assistant.' },
|
|
16
|
+
# { role: 'user', content: 'こんにちは' }
|
|
17
|
+
# ] },
|
|
18
|
+
# binding: ai
|
|
19
|
+
# ).__await__
|
|
20
|
+
# out['response'] # => "..."
|
|
21
|
+
#
|
|
22
|
+
# Streaming (`stream: true`) returns the raw JS ReadableStream wrapped
|
|
23
|
+
# in `Cloudflare::AI::Stream` so a route can hand it to a Server-Sent
|
|
24
|
+
# Events response. See `lib/cloudflare_workers.rb#build_js_response`
|
|
25
|
+
# for the SSE / ReadableStream pass-through.
|
|
26
|
+
|
|
27
|
+
require 'json'
|
|
28
|
+
|
|
29
|
+
module Cloudflare
|
|
30
|
+
class AIError < StandardError
|
|
31
|
+
attr_reader :model, :operation
|
|
32
|
+
def initialize(message, model: nil, operation: nil)
|
|
33
|
+
@model = model
|
|
34
|
+
@operation = operation
|
|
35
|
+
super("[Cloudflare::AI] model=#{model || '?'} op=#{operation || 'run'}: #{message}")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
module AI
|
|
40
|
+
# Default REST options forwarded to env.AI.run as the third argument.
|
|
41
|
+
DEFAULT_OPTIONS = {}.freeze
|
|
42
|
+
|
|
43
|
+
# Run a Workers AI model. Returns a JS Promise that resolves to a
|
|
44
|
+
# Ruby Hash for non-streaming calls, or to a Cloudflare::AI::Stream
|
|
45
|
+
# wrapping the JS ReadableStream for streaming calls.
|
|
46
|
+
#
|
|
47
|
+
# @param model [String] catalog model id, e.g. '@cf/google/gemma-4-26b-a4b-it'
|
|
48
|
+
# @param inputs [Hash] model inputs (messages / prompt / max_tokens / etc.)
|
|
49
|
+
# @param binding [JS object] env.AI binding (required)
|
|
50
|
+
# @param options [Hash] gateway / extra options forwarded as the 3rd arg
|
|
51
|
+
def self.run(model, inputs, binding: nil, options: nil)
|
|
52
|
+
# Use a JS-side null check because `binding` may be a raw JS object
|
|
53
|
+
# (env.AI), which has no Ruby `#nil?` method on the prototype.
|
|
54
|
+
bound = !`(#{binding} == null)`
|
|
55
|
+
raise AIError.new('AI binding not bound (env.AI is null)', model: model) unless bound
|
|
56
|
+
js_inputs = ruby_to_js(inputs)
|
|
57
|
+
js_options = options ? ruby_to_js(options) : `({})`
|
|
58
|
+
ai_binding = binding
|
|
59
|
+
err_klass = Cloudflare::AIError
|
|
60
|
+
stream_klass = Cloudflare::AI::Stream
|
|
61
|
+
# Streaming may be requested either via `inputs[:stream]` (the
|
|
62
|
+
# newer Workers AI shape) or `options: { stream: true }` (the
|
|
63
|
+
# 3rd-arg "options" contract). Accept both so callers can use
|
|
64
|
+
# whichever idiom matches the model docs they're following.
|
|
65
|
+
streaming = (inputs.is_a?(Hash) && (inputs[:stream] == true || inputs['stream'] == true)) ||
|
|
66
|
+
(options.is_a?(Hash) && (options[:stream] == true || options['stream'] == true))
|
|
67
|
+
cf = Cloudflare
|
|
68
|
+
|
|
69
|
+
# NOTE: multi-line backtick → Promise works HERE because the
|
|
70
|
+
# value is assigned to `js_promise` (Opal emits the statement AND
|
|
71
|
+
# keeps the returned value alive through the local). Do NOT
|
|
72
|
+
# refactor this so the backtick is the method's last expression
|
|
73
|
+
# or the Promise will be silently dropped (same pitfall
|
|
74
|
+
# documented in lib/cloudflare_workers/{cache,queue}.rb —
|
|
75
|
+
# Phase 11B audit).
|
|
76
|
+
js_promise = `
|
|
77
|
+
(async function() {
|
|
78
|
+
var out;
|
|
79
|
+
try {
|
|
80
|
+
out = await #{ai_binding}.run(#{model}, #{js_inputs}, #{js_options});
|
|
81
|
+
} catch (e) {
|
|
82
|
+
#{Kernel}.$raise(#{err_klass}.$new(e && e.message ? e.message : String(e), Opal.hash({ model: #{model}, operation: 'run' })));
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
})()
|
|
86
|
+
`
|
|
87
|
+
|
|
88
|
+
js_result = js_promise.__await__
|
|
89
|
+
|
|
90
|
+
if streaming
|
|
91
|
+
# Workers AI returns a ReadableStream<Uint8Array> when stream:true.
|
|
92
|
+
# Wrap it so the Sinatra route can return it as an SSE body.
|
|
93
|
+
stream_klass.new(js_result)
|
|
94
|
+
else
|
|
95
|
+
cf.js_to_ruby(js_result)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Convert a Ruby value (Hash / Array / String / Numeric / true / false / nil)
|
|
100
|
+
# into a plain JS object suitable for env.AI.run inputs.
|
|
101
|
+
def self.ruby_to_js(val)
|
|
102
|
+
if val.is_a?(Hash)
|
|
103
|
+
obj = `({})`
|
|
104
|
+
val.each do |k, v|
|
|
105
|
+
ks = k.to_s
|
|
106
|
+
jv = ruby_to_js(v)
|
|
107
|
+
`#{obj}[#{ks}] = #{jv}`
|
|
108
|
+
end
|
|
109
|
+
obj
|
|
110
|
+
elsif val.is_a?(Array)
|
|
111
|
+
arr = `([])`
|
|
112
|
+
val.each do |v|
|
|
113
|
+
jv = ruby_to_js(v)
|
|
114
|
+
`#{arr}.push(#{jv})`
|
|
115
|
+
end
|
|
116
|
+
arr
|
|
117
|
+
elsif val.is_a?(Symbol)
|
|
118
|
+
val.to_s
|
|
119
|
+
else
|
|
120
|
+
val
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Streaming wrapper. Holds the raw JS ReadableStream<Uint8Array>
|
|
125
|
+
# returned by env.AI.run when `stream: true` is set. Sinatra routes
|
|
126
|
+
# return this from a route body and `build_js_response` recognises
|
|
127
|
+
# it via duck-typing (`#sse_stream?`) to pass the stream straight
|
|
128
|
+
# into `new Response(stream, …)`.
|
|
129
|
+
#
|
|
130
|
+
# Phase 11A: unified with Cloudflare::SSEStream so both stream
|
|
131
|
+
# types go through the same `response_headers` path in
|
|
132
|
+
# `build_js_response`. The AI::Stream wraps a pre-existing JS
|
|
133
|
+
# ReadableStream (produced by env.AI.run), whereas SSEStream
|
|
134
|
+
# produces its own. The adapter doesn't need to care — it just
|
|
135
|
+
# calls `#js_stream` and `#response_headers`.
|
|
136
|
+
class Stream
|
|
137
|
+
attr_reader :js_stream
|
|
138
|
+
|
|
139
|
+
def initialize(js_stream, headers: nil)
|
|
140
|
+
@js_stream = js_stream
|
|
141
|
+
@extra_headers = headers || {}
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def sse_stream?
|
|
145
|
+
true
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Merged SSE headers — same shape as SSEStream#response_headers,
|
|
149
|
+
# so build_js_response can pass the stream through without a
|
|
150
|
+
# special AI branch. Reference Cloudflare::SSEStream lazily so
|
|
151
|
+
# this file still loads if stream.rb hasn't been required yet
|
|
152
|
+
# (Phase 11A load-order flip: ai.rb currently loads first).
|
|
153
|
+
def response_headers
|
|
154
|
+
defaults = defined?(::Cloudflare::SSEStream) ?
|
|
155
|
+
::Cloudflare::SSEStream::DEFAULT_HEADERS :
|
|
156
|
+
{ 'content-type' => 'text/event-stream; charset=utf-8',
|
|
157
|
+
'cache-control' => 'no-cache, no-transform',
|
|
158
|
+
'x-accel-buffering' => 'no' }
|
|
159
|
+
defaults.merge(@extra_headers)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def each; end
|
|
163
|
+
def close; end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Generic JS->Ruby for the common Workers AI response shape:
|
|
168
|
+
# { response: "...", usage: { prompt_tokens: ... } }
|
|
169
|
+
# Recursively converts nested objects + arrays.
|
|
170
|
+
def self.js_to_ruby(js_val)
|
|
171
|
+
return nil if `#{js_val} == null`
|
|
172
|
+
return js_val if `typeof #{js_val} === 'string' || typeof #{js_val} === 'number' || typeof #{js_val} === 'boolean'`
|
|
173
|
+
if `Array.isArray(#{js_val})`
|
|
174
|
+
out = []
|
|
175
|
+
len = `#{js_val}.length`
|
|
176
|
+
i = 0
|
|
177
|
+
while i < len
|
|
178
|
+
out << js_to_ruby(`#{js_val}[#{i}]`)
|
|
179
|
+
i += 1
|
|
180
|
+
end
|
|
181
|
+
return out
|
|
182
|
+
end
|
|
183
|
+
if `typeof #{js_val} === 'object'`
|
|
184
|
+
h = {}
|
|
185
|
+
keys = `Object.keys(#{js_val})`
|
|
186
|
+
len = `#{keys}.length`
|
|
187
|
+
i = 0
|
|
188
|
+
while i < len
|
|
189
|
+
k = `#{keys}[#{i}]`
|
|
190
|
+
h[k] = js_to_ruby(`#{js_val}[#{k}]`)
|
|
191
|
+
i += 1
|
|
192
|
+
end
|
|
193
|
+
return h
|
|
194
|
+
end
|
|
195
|
+
js_val
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module CloudflareWorkers
|
|
6
|
+
class AsyncRegistry
|
|
7
|
+
class Builder
|
|
8
|
+
def initialize(registry)
|
|
9
|
+
@registry = registry
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def async_class(class_name, except: [:new])
|
|
13
|
+
@registry.async_classes[class_name] = except.to_set
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def async_method(class_name, method_name)
|
|
17
|
+
(@registry.async_methods[class_name] ||= Set.new) << method_name
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def async_factory(class_name, method_name)
|
|
21
|
+
(@registry.async_factories[class_name] ||= Set.new) << method_name
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def taint_return(class_name, method_name, return_class_name)
|
|
25
|
+
(@registry.taint_returns[class_name] ||= {})[method_name] = return_class_name
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def async_accessor(lvar_name, accessor_name, class_name)
|
|
29
|
+
@registry.async_accessors[[lvar_name.to_sym, accessor_name.to_sym]] = class_name
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def async_helper(method_name, class_name)
|
|
33
|
+
(@registry.async_helpers[method_name.to_sym] ||= Set.new) << class_name
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def helper_factory(method_name, class_name)
|
|
37
|
+
@registry.helper_factories[method_name.to_sym] = class_name
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
def register_async_source(&block)
|
|
43
|
+
builder = Builder.new(instance)
|
|
44
|
+
builder.instance_eval(&block)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def instance
|
|
48
|
+
@instance ||= new
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def reset!
|
|
52
|
+
@instance = new
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def async?(class_name, method_name)
|
|
56
|
+
instance.async?(class_name, method_name)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def factory?(class_name, method_name)
|
|
60
|
+
instance.factory?(class_name, method_name)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def taint_return_class(class_name, method_name)
|
|
64
|
+
instance.taint_return_class(class_name, method_name)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def tainted_class?(class_name)
|
|
68
|
+
instance.tainted_class?(class_name)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def auto_load_gem_async_sources(debug: false)
|
|
72
|
+
return unless defined?(Gem) && Gem.respond_to?(:loaded_specs)
|
|
73
|
+
|
|
74
|
+
loaded = 0
|
|
75
|
+
Gem.loaded_specs.each_value do |spec|
|
|
76
|
+
next if spec.full_gem_path.nil?
|
|
77
|
+
|
|
78
|
+
lib_dir = File.join(spec.full_gem_path, 'lib')
|
|
79
|
+
next unless Dir.exist?(lib_dir)
|
|
80
|
+
|
|
81
|
+
Dir.glob(File.join(lib_dir, '**', '*.rb')).each do |path|
|
|
82
|
+
next unless File.read(path, 8192).include?('register_async_source')
|
|
83
|
+
|
|
84
|
+
require_path = path.sub(Regexp.new("^#{Regexp.escape(lib_dir)}/"), '').sub(/\.rb\z/, '')
|
|
85
|
+
begin
|
|
86
|
+
require require_path
|
|
87
|
+
loaded += 1
|
|
88
|
+
puts "[auto-await] loaded async source from #{spec.name}: #{require_path}" if debug
|
|
89
|
+
rescue LoadError, StandardError => e
|
|
90
|
+
warn "[auto-await] Warning: failed to load async source from #{spec.name}/#{require_path}: #{e.message}" if debug
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
puts "[auto-await] auto-loaded #{loaded} async source file(s)" if debug && loaded.positive?
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
attr_reader :async_classes, :async_methods, :async_factories, :taint_returns, :async_accessors, :async_helpers, :helper_factories
|
|
100
|
+
|
|
101
|
+
def initialize
|
|
102
|
+
@async_classes = {}
|
|
103
|
+
@async_methods = {}
|
|
104
|
+
@async_factories = {}
|
|
105
|
+
@taint_returns = {}
|
|
106
|
+
@async_accessors = {}
|
|
107
|
+
@async_helpers = {}
|
|
108
|
+
@helper_factories = {}
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def async?(class_name, method_name)
|
|
112
|
+
return false if method_name == :new
|
|
113
|
+
methods = @async_methods[class_name]
|
|
114
|
+
return true if methods&.include?(method_name)
|
|
115
|
+
except = @async_classes[class_name]
|
|
116
|
+
return true if except && !except.include?(method_name.to_s) && !except.include?(method_name.to_sym)
|
|
117
|
+
false
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def factory?(class_name, method_name)
|
|
121
|
+
@async_factories[class_name]&.include?(method_name)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def taint_return_class(class_name, method_name)
|
|
125
|
+
@taint_returns[class_name]&.[](method_name)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def tainted_class?(class_name)
|
|
129
|
+
@async_classes.key?(class_name) ||
|
|
130
|
+
@async_methods.key?(class_name) ||
|
|
131
|
+
@async_factories.key?(class_name) ||
|
|
132
|
+
@taint_returns.key?(class_name)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Phase 17.5 — Auto-Await: register runtime gem async sources.
|
|
138
|
+
# Each binding declares which methods return Promises so the
|
|
139
|
+
# build-time analyzer can insert .__await__ automatically.
|
|
140
|
+
CloudflareWorkers::AsyncRegistry.register_async_source do
|
|
141
|
+
async_method 'Cloudflare::D1Database', :execute
|
|
142
|
+
async_method 'Cloudflare::D1Database', :get_first_row
|
|
143
|
+
async_method 'Cloudflare::D1Database', :execute_insert
|
|
144
|
+
async_method 'Cloudflare::D1Database', :execute_batch
|
|
145
|
+
taint_return 'Cloudflare::D1Database', :prepare, 'Cloudflare::D1Statement'
|
|
146
|
+
taint_return 'Cloudflare::D1Database', :[], 'Cloudflare::D1Statement'
|
|
147
|
+
|
|
148
|
+
async_method 'Cloudflare::D1Statement', :all
|
|
149
|
+
async_method 'Cloudflare::D1Statement', :first
|
|
150
|
+
async_method 'Cloudflare::D1Statement', :run
|
|
151
|
+
|
|
152
|
+
async_method 'Cloudflare::KVNamespace', :get
|
|
153
|
+
async_method 'Cloudflare::KVNamespace', :get_with_metadata
|
|
154
|
+
async_method 'Cloudflare::KVNamespace', :put
|
|
155
|
+
async_method 'Cloudflare::KVNamespace', :delete
|
|
156
|
+
async_method 'Cloudflare::KVNamespace', :list
|
|
157
|
+
|
|
158
|
+
async_method 'Cloudflare::R2Bucket', :get
|
|
159
|
+
async_method 'Cloudflare::R2Bucket', :get_binary
|
|
160
|
+
async_method 'Cloudflare::R2Bucket', :put
|
|
161
|
+
async_method 'Cloudflare::R2Bucket', :delete
|
|
162
|
+
async_method 'Cloudflare::R2Bucket', :list
|
|
163
|
+
async_method 'Cloudflare::R2Bucket', :head
|
|
164
|
+
|
|
165
|
+
async_method 'Cloudflare::AI', :run
|
|
166
|
+
taint_return 'Cloudflare::AI', :run_stream, 'Cloudflare::AI::Stream'
|
|
167
|
+
|
|
168
|
+
async_method 'Cloudflare::Cache', :match
|
|
169
|
+
async_method 'Cloudflare::Cache', :put
|
|
170
|
+
async_method 'Cloudflare::Cache', :delete
|
|
171
|
+
|
|
172
|
+
async_factory 'Cloudflare::Email', :new
|
|
173
|
+
async_method 'Cloudflare::Email', :send
|
|
174
|
+
|
|
175
|
+
async_method 'Cloudflare::Queue', :send
|
|
176
|
+
async_method 'Cloudflare::Queue', :send_batch
|
|
177
|
+
|
|
178
|
+
async_factory 'Cloudflare::DurableObjectNamespace', :new
|
|
179
|
+
taint_return 'Cloudflare::DurableObjectNamespace', :get, 'Cloudflare::DurableObjectStub'
|
|
180
|
+
taint_return 'Cloudflare::DurableObjectNamespace', :get_by_name, 'Cloudflare::DurableObjectStub'
|
|
181
|
+
taint_return 'Cloudflare::DurableObjectState', :storage, 'Cloudflare::DurableObjectStorage'
|
|
182
|
+
async_method 'Cloudflare::DurableObjectStub', :fetch
|
|
183
|
+
|
|
184
|
+
async_method 'Cloudflare::DurableObjectStorage', :get
|
|
185
|
+
async_method 'Cloudflare::DurableObjectStorage', :put
|
|
186
|
+
async_method 'Cloudflare::DurableObjectStorage', :delete
|
|
187
|
+
async_method 'Cloudflare::DurableObjectStorage', :list
|
|
188
|
+
async_method 'Cloudflare::DurableObjectStorage', :transaction
|
|
189
|
+
|
|
190
|
+
async_method 'Cloudflare::HTTP', :fetch
|
|
191
|
+
|
|
192
|
+
async_method 'Faraday::Connection', :get
|
|
193
|
+
async_method 'Faraday::Connection', :post
|
|
194
|
+
async_method 'Faraday::Connection', :put
|
|
195
|
+
async_method 'Faraday::Connection', :delete
|
|
196
|
+
async_method 'Faraday::Connection', :patch
|
|
197
|
+
async_method 'Faraday::Connection', :head
|
|
198
|
+
end
|