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,180 @@
1
+ # frozen_string_literal: true
2
+ # backtick_javascript: true
3
+ # await: true
4
+ #
5
+ # Phase 17 — Cloudflare Email Service (`SEND_EMAIL` binding).
6
+ #
7
+ # Cloudflare::Email.new(env.SEND_EMAIL).send(
8
+ # to: 'u@example.com',
9
+ # from: 'noreply@yourdomain.com',
10
+ # subject: 'Hello',
11
+ # text: 'Plain body'
12
+ # ).__await__
13
+ #
14
+ # See https://developers.cloudflare.com/email-service/api/send-emails/workers-api/
15
+
16
+ module Cloudflare
17
+ class Email
18
+ class Error < StandardError
19
+ attr_reader :code
20
+
21
+ def initialize(message, code: nil)
22
+ @code = code
23
+ super(message.to_s)
24
+ end
25
+ end
26
+
27
+ attr_reader :js
28
+
29
+ def initialize(js)
30
+ @js = js
31
+ end
32
+
33
+ def available?
34
+ js = @js
35
+ !!`(#{js} !== null && #{js} !== undefined && #{js} !== Opal.nil)`
36
+ end
37
+
38
+ # Workers `env.SEND_EMAIL.send({ to, from, subject, text?, html?, replyTo? })`.
39
+ # `to`: String, Array (nested), or `{ email:, name?: }`; name is forwarded to Workers as
40
+ # `{ email, name }` entries when present. `from` / `reply_to`: String or `{ email:, name?: }`.
41
+ # At least one of `text:` or `html:` is required.
42
+ def send(to:, from:, subject:, text: nil, html: nil, reply_to: nil)
43
+ js = @js
44
+ err_klass = Cloudflare::Email::Error
45
+ raise Error, 'send_email binding not bound' unless available?
46
+
47
+ raise Error, 'subject is required' if subject.nil? || subject.to_s.strip.empty?
48
+
49
+ has_text = !(text.nil? || text.to_s.empty?)
50
+ has_html = !(html.nil? || html.to_s.empty?)
51
+ raise Error, 'text or html is required' unless has_text || has_html
52
+
53
+ payload = build_send_payload(to: to, from: from, subject: subject.to_s, text: text, html: html, reply_to: reply_to)
54
+
55
+ cf = Cloudflare
56
+ # 多行 x-string をメソッド末尾に置くと Opal が Promise を返さない出力になることがあるため return を明示する。
57
+ return `(async function(binding, payload, Kernel, Err, cf) {
58
+ try {
59
+ var r = await binding.send(payload);
60
+ if (r == null || r === undefined) {
61
+ var o0 = {}; o0['message_id'] = ''; o0['cf_send_result_json'] = '"void"';
62
+ return cf.$js_to_ruby(o0);
63
+ }
64
+ var raw = '';
65
+ try { raw = JSON.stringify(r); } catch (x1) { raw = String(r); }
66
+ var mid = r.messageId != null ? String(r.messageId)
67
+ : (r.message_id != null ? String(r.message_id) : '');
68
+ var o = {}; o['message_id'] = mid; o['cf_send_result_json'] = raw;
69
+ return cf.$js_to_ruby(o);
70
+ } catch (e) {
71
+ var code = (e && e.code != null) ? String(e.code) : '';
72
+ var msg = (e && e.message) ? String(e.message) : String(e);
73
+ Kernel.$raise(Err.$new(msg, Opal.hash({ code: code })));
74
+ }
75
+ })(#{js}, #{payload}, #{Kernel}, #{err_klass}, #{cf})`
76
+ end
77
+
78
+ private
79
+
80
+ def build_send_payload(to:, from:, subject:, text:, html:, reply_to:)
81
+ obj = `({})`
82
+ # Cloudflare Workers API は payload の text/html/subject を JS の primitive string として期待する。
83
+ # Opal の String は typeof === 'object' のため、そのまま代入すると multipart の html が無視されることがある。
84
+ `#{obj}.subject = #{subject}.toString()`
85
+
86
+ # --- to (string | mixed array per Workers API) ---------------------
87
+ to_js = normalize_to_js(to)
88
+ `#{obj}.to = #{to_js}`
89
+
90
+ # --- from -----------------------------------------------------------
91
+ `#{obj}.from = #{normalize_from_js(from)}`
92
+
93
+ `#{obj}.text = #{text}.toString()` if !(text.nil? || text.to_s.empty?)
94
+ `#{obj}.html = #{html}.toString()` if !(html.nil? || html.to_s.empty?)
95
+
96
+ # --- replyTo (camelCase in Workers API) -----------------------------
97
+ rt_js = normalize_optional_reply_js(reply_to)
98
+ `#{obj}.replyTo = #{rt_js}` unless rt_js.nil?
99
+
100
+ obj
101
+ end
102
+
103
+ # Returns a JS array: mix of address strings and `{ email, name? }` objects (Workers API shape).
104
+ def normalize_to_js(raw)
105
+ entries = flatten_recipients(raw)
106
+ raise Error, 'to is empty' if entries.empty?
107
+
108
+ arr = `([])`
109
+ entries.each do |e|
110
+ case e
111
+ when String
112
+ `#{arr}.push(#{e})`
113
+ when Hash
114
+ js = `({})`
115
+ `#{js}.email = #{e[:email]}`
116
+ `#{js}.name = #{e[:name].to_s}` if e[:name] && !e[:name].to_s.strip.empty?
117
+ `#{arr}.push(#{js})`
118
+ end
119
+ end
120
+ arr
121
+ end
122
+
123
+ # Returns Ruby strings (bare address) or `{ email:, name: }` when a display name was given.
124
+ def flatten_recipients(raw)
125
+ case raw
126
+ when nil
127
+ []
128
+ when String
129
+ s = raw.strip
130
+ s.empty? ? [] : [s]
131
+ when Hash
132
+ em = raw[:email] || raw['email']
133
+ return [] if em.nil? || em.to_s.strip.empty?
134
+ nm = raw[:name] || raw['name']
135
+ if nm.nil? || nm.to_s.strip.empty?
136
+ [em.to_s.strip]
137
+ else
138
+ [{ email: em.to_s.strip, name: nm.to_s }]
139
+ end
140
+ when Array
141
+ raw.flat_map { |x| flatten_recipients(x) }
142
+ else
143
+ s = raw.to_s.strip
144
+ s.empty? ? [] : [s]
145
+ end
146
+ end
147
+
148
+ def normalize_from_js(raw)
149
+ normalize_address_js(raw)
150
+ end
151
+
152
+ def normalize_optional_reply_js(raw)
153
+ return nil if raw.nil?
154
+ return nil if raw.is_a?(Array) && raw.empty?
155
+ return normalize_optional_reply_js(raw.first) if raw.is_a?(Array)
156
+
157
+ normalize_address_js(raw)
158
+ end
159
+
160
+ # Returns a JS string ("a@b.com") or object { email, name }.
161
+ def normalize_address_js(raw)
162
+ case raw
163
+ when String
164
+ s = raw.strip
165
+ raise Error, 'from address is empty' if s.empty?
166
+ return s
167
+ when Hash
168
+ em = raw[:email] || raw['email']
169
+ nm = raw[:name] || raw['name']
170
+ raise Error, 'from.email is required' if em.nil? || em.to_s.strip.empty?
171
+ js = `({})`
172
+ `#{js}.email = #{em.to_s.strip}`
173
+ `#{js}.name = #{nm.to_s}` unless nm.nil? || nm.to_s.strip.empty?
174
+ js
175
+ else
176
+ normalize_address_js(raw.to_s)
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+ # backtick_javascript: true
3
+ # await: true
4
+ #
5
+ # Phase 6 — HTTP client foundation.
6
+ #
7
+ # Cloudflare::HTTP.fetch wraps globalThis.fetch (V8 / Node.js / Workers)
8
+ # and exposes a Ruby-friendly response object. Sinatra routes use
9
+ # `.__await__` inside a `# await: true` block, the same pattern that
10
+ # the D1/KV/R2 wrappers use for their JS Promises.
11
+ #
12
+ # This module is the single bridge that lets unmodified Ruby HTTP gems
13
+ # (Net::HTTP, Faraday adapters, OpenAI clients, etc.) reach the
14
+ # network. There is no socket-level fallback — everything ultimately
15
+ # goes through `globalThis.fetch`, because Cloudflare Workers does not
16
+ # expose a TCP socket API.
17
+
18
+ require 'json'
19
+
20
+ module Cloudflare
21
+ class HTTPError < StandardError
22
+ attr_reader :url, :method
23
+ def initialize(message, url: nil, method: nil)
24
+ @url = url
25
+ @method = method
26
+ super("[Cloudflare::HTTP] #{method || 'GET'} #{url}: #{message}")
27
+ end
28
+ end
29
+
30
+ # Lightweight response wrapper around the JS Response object.
31
+ # The body is read eagerly (text()) so callers see a plain Ruby
32
+ # String. `headers` is a frozen Hash with lowercased string keys.
33
+ class HTTPResponse
34
+ attr_reader :status, :headers, :body, :url
35
+
36
+ def initialize(status:, headers:, body:, url:)
37
+ @status = status
38
+ @headers = headers
39
+ @body = body
40
+ @url = url
41
+ end
42
+
43
+ def ok?
44
+ @status >= 200 && @status < 300
45
+ end
46
+
47
+ def json
48
+ JSON.parse(@body)
49
+ end
50
+
51
+ def [](name)
52
+ @headers[name.to_s.downcase]
53
+ end
54
+ end
55
+
56
+ module HTTP
57
+ DEFAULT_HEADERS = {}.freeze
58
+
59
+ # Issue an HTTP request via globalThis.fetch.
60
+ #
61
+ # res = Cloudflare::HTTP.fetch('https://api.example.com/users',
62
+ # method: 'POST',
63
+ # headers: { 'content-type' => 'application/json' },
64
+ # body: { name: 'kazu' }.to_json).__await__
65
+ # res.status # => 200
66
+ # res.json # => { 'id' => 1, 'name' => 'kazu' }
67
+ #
68
+ # The whole response body is awaited and returned as a String.
69
+ # Use `Cloudflare::HTTPResponse#body` to access raw text.
70
+ def self.fetch(url, method: 'GET', headers: nil, body: nil)
71
+ hdrs = headers || DEFAULT_HEADERS
72
+ method_str = method.to_s.upcase
73
+ js_headers = ruby_headers_to_js(hdrs)
74
+ js_body = body.nil? ? nil : body.to_s
75
+ url_str = url.to_s
76
+ response_klass = Cloudflare::HTTPResponse
77
+ err_klass = Cloudflare::HTTPError
78
+ headers_to_hash = method(:js_headers_to_hash)
79
+ _ = headers_to_hash # silence opal lint
80
+
81
+ # NOTE: the multi-line backtick below returns a Promise BECAUSE it
82
+ # is assigned to `js_promise` (and `js_promise.__await__` awaits
83
+ # it). Opal treats a multi-line x-string as a statement, not an
84
+ # expression — safe when assigned, but a bare multi-line backtick
85
+ # at end-of-method silently drops the Promise. Do NOT refactor
86
+ # this into `def fetch ... end` with the backtick as the last
87
+ # expression. See the single-line IIFE pattern used in
88
+ # lib/cloudflare_workers/{cache,queue,durable_object}.rb#put for
89
+ # the alternative that survives either position. (Phase 11B audit.)
90
+ js_promise = `
91
+ (async function() {
92
+ var init = { method: #{method_str}, headers: #{js_headers}, redirect: 'follow' };
93
+ if (#{js_body} !== nil && #{js_body} != null) { init.body = #{js_body}; }
94
+ var resp;
95
+ try {
96
+ resp = await globalThis.fetch(#{url_str}, init);
97
+ } catch (e) {
98
+ #{Kernel}.$$raise(#{err_klass}.$new(e.message || String(e), Opal.hash({ url: #{url_str}, method: #{method_str} })));
99
+ }
100
+ var text = '';
101
+ try {
102
+ text = await resp.text();
103
+ } catch (e) {
104
+ text = '';
105
+ }
106
+ var hash_keys = [];
107
+ var hash_vals = [];
108
+ if (resp.headers && typeof resp.headers.forEach === 'function') {
109
+ resp.headers.forEach(function(value, key) {
110
+ hash_keys.push(String(key).toLowerCase());
111
+ hash_vals.push(String(value));
112
+ });
113
+ }
114
+ return { status: resp.status|0, text: text, hkeys: hash_keys, hvals: hash_vals };
115
+ })()
116
+ `
117
+
118
+ js_result = js_promise.__await__
119
+ hkeys = `#{js_result}.hkeys`
120
+ hvals = `#{js_result}.hvals`
121
+ h = {}
122
+ i = 0
123
+ len = `#{hkeys}.length`
124
+ while i < len
125
+ h[`#{hkeys}[#{i}]`] = `#{hvals}[#{i}]`
126
+ i += 1
127
+ end
128
+
129
+ response_klass.new(
130
+ status: `#{js_result}.status`,
131
+ headers: h,
132
+ body: `#{js_result}.text`,
133
+ url: url_str
134
+ )
135
+ end
136
+
137
+ # Convert a Ruby Hash<String, String> into a plain JS object usable
138
+ # as the `headers` init for fetch().
139
+ def self.ruby_headers_to_js(hash)
140
+ js_obj = `{}`
141
+ hash.each do |k, v|
142
+ ks = k.to_s
143
+ vs = v.to_s
144
+ `#{js_obj}[#{ks}] = #{vs}`
145
+ end
146
+ js_obj
147
+ end
148
+
149
+ # Internal: convert JS Headers / iterable to Ruby Hash with
150
+ # lowercased string keys. Currently inlined in fetch() but exposed
151
+ # for completeness.
152
+ def self.js_headers_to_hash(js_headers)
153
+ h = {}
154
+ `
155
+ if (#{js_headers} && typeof #{js_headers}.forEach === 'function') {
156
+ #{js_headers}.forEach(function(value, key) {
157
+ #{h}.$$smap[String(key).toLowerCase()] = String(value);
158
+ });
159
+ }
160
+ `
161
+ h
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,332 @@
1
+ # frozen_string_literal: true
2
+ # backtick_javascript: true
3
+ #
4
+ # Phase 11A — multipart/form-data receive pipeline.
5
+ #
6
+ # Why a bespoke parser instead of Rack::Multipart::Parser?
7
+ #
8
+ # Rack's parser does work on Opal in principle (strscan is in stdlib),
9
+ # but it relies on Tempfile — which is a stub on Workers since there is
10
+ # no writable filesystem. It also assumes the request body is a true
11
+ # binary ByteString, whereas on Workers we have to cross the JS/Ruby
12
+ # boundary and Opal Strings are JS Strings (UTF-16 code units). The
13
+ # correct way to pass bytes through that boundary is to encode each
14
+ # byte as a single `char code 0-255` latin1 character, then `unescape
15
+ # / String.fromCharCode.apply` back into a Uint8Array when we need
16
+ # raw bytes again (e.g. R2.put).
17
+ #
18
+ # This module exposes:
19
+ #
20
+ # Cloudflare::Multipart.parse(body_binstr, content_type)
21
+ # → Hash[String => Cloudflare::UploadedFile | String]
22
+ #
23
+ # Cloudflare::UploadedFile
24
+ # #filename — original filename from the Content-Disposition header
25
+ # #content_type — part Content-Type (defaults to application/octet-stream)
26
+ # #name — form field name
27
+ # #size — byte length
28
+ # #bytes_binstr — the latin1-encoded byte string (1 char = 1 byte)
29
+ # #to_uint8_array — JS Uint8Array suitable for fetch body / R2.put
30
+ # #read — returns bytes_binstr, mirroring Tempfile#read
31
+ # #rewind — no-op (content is fully in-memory, there is no
32
+ # writable filesystem on Workers)
33
+ #
34
+ # Also installs `Rack::Request#post?`-path hook: when the Sinatra route
35
+ # calls `params['file']`, `Rack::Request#POST` delegates to
36
+ # `Cloudflare::Multipart.rack_params(env)` which parses once, caches on
37
+ # the env, and hydrates `params` with UploadedFile / String values.
38
+
39
+ module Cloudflare
40
+ # Struct-ish wrapper for an uploaded file part. Identical shape to
41
+ # the `{:filename, :type, :name, :tempfile, :head}` Hash Rack's
42
+ # parser returns, plus extras for the Workers use case.
43
+ class UploadedFile
44
+ attr_reader :filename, :content_type, :name, :head, :bytes_binstr
45
+
46
+ def initialize(filename:, content_type:, name:, head: '', bytes_binstr: '')
47
+ @filename = filename
48
+ @content_type = content_type || 'application/octet-stream'
49
+ @name = name
50
+ @head = head
51
+ @bytes_binstr = bytes_binstr || ''
52
+ end
53
+
54
+ # Byte length of the part (not the JS string length — they're the
55
+ # same here because we use latin1 1-byte-per-char encoding).
56
+ def size
57
+ @bytes_binstr.length
58
+ end
59
+ alias_method :bytesize, :size
60
+
61
+ # Convenience accessor matching the CRuby Rack shape.
62
+ def type
63
+ @content_type
64
+ end
65
+
66
+ # Read the full byte string. Mirrors Tempfile#read.
67
+ def read
68
+ @bytes_binstr
69
+ end
70
+
71
+ def rewind
72
+ self
73
+ end
74
+
75
+ def close
76
+ self
77
+ end
78
+
79
+ # Convert the latin1 byte-string to a real JS Uint8Array. Used to
80
+ # feed raw bytes to `env.BUCKET.put`, `globalThis.fetch(body: ...)`,
81
+ # `Blob`, etc. without re-encoding through UTF-8.
82
+ #
83
+ # NOTE: single-line backtick x-string so Opal emits it as an
84
+ # expression (multi-line x-strings compile to raw statements and
85
+ # would silently return `undefined`). Same gotcha documented
86
+ # elsewhere in this codebase (see lib/cloudflare_workers.rb).
87
+ def to_uint8_array
88
+ `(function(s) { var len = s.length; var out = new Uint8Array(len); for (var i = 0; i < len; i++) { out[i] = s.charCodeAt(i) & 0xff; } return out; })(#{@bytes_binstr})`
89
+ end
90
+
91
+ # Convert to a JS Blob for fetch/Response bodies.
92
+ def to_blob
93
+ u8 = to_uint8_array
94
+ ct = @content_type
95
+ `new Blob([#{u8}], { type: #{ct} })`
96
+ end
97
+
98
+ # Rack-friendly Hash view. Match the exact shape Rack::Multipart
99
+ # produces so gems that do `params['file'][:filename]` keep working.
100
+ def to_h
101
+ {
102
+ filename: @filename,
103
+ type: @content_type,
104
+ name: @name,
105
+ head: @head,
106
+ tempfile: self
107
+ }
108
+ end
109
+ alias_method :to_hash, :to_h
110
+
111
+ # `#[]` so `file[:filename]` works on the UploadedFile itself
112
+ # (some gems use the Hash shape, some grab the file object —
113
+ # support both access patterns to reduce downstream surprises).
114
+ def [](key)
115
+ to_h[key.to_sym]
116
+ end
117
+ end
118
+
119
+ module Multipart
120
+ CRLF = "\r\n"
121
+
122
+ # Extract the multipart boundary from a Content-Type header.
123
+ # Matches `boundary=AaB03x`, `boundary="weird boundary"`,
124
+ # and whitespace/case variants. Quoted forms are preserved as-is
125
+ # so `boundary="foo bar"` → `foo bar` (internal whitespace kept),
126
+ # while unquoted forms stop at the next delimiter.
127
+ def self.parse_boundary(content_type)
128
+ return nil if content_type.nil?
129
+ ct = content_type.to_s
130
+ return nil unless ct.downcase.include?('multipart/')
131
+ # Prefer the quoted form. The quoted value may contain any byte
132
+ # except a literal `"` (RFC 2046 §5.1.1 bans `"` in the value).
133
+ if (m = ct.match(/boundary="([^"]+)"/i))
134
+ return m[1]
135
+ end
136
+ if (m = ct.match(/boundary=([^;,\s]+)/i))
137
+ return m[1]
138
+ end
139
+ nil
140
+ end
141
+
142
+ # Parse a multipart/form-data payload.
143
+ #
144
+ # @param body_binstr [String] latin1 byte string (1 char = 1 byte)
145
+ # @param content_type [String] the request Content-Type header
146
+ # @return [Hash] keys are form-field names (strings); values are
147
+ # either UploadedFile (for file parts) or String (for plain
148
+ # text fields).
149
+ def self.parse(body_binstr, content_type)
150
+ boundary = parse_boundary(content_type)
151
+ return {} if boundary.nil?
152
+ return {} if body_binstr.nil? || body_binstr.empty?
153
+
154
+ sep = '--' + boundary
155
+ term = '--' + boundary + '--'
156
+ sep_line = sep + CRLF
157
+ sep_last = sep + CRLF # the very first boundary may skip the leading CRLF
158
+ body = body_binstr.to_s
159
+
160
+ # Skip any preamble before the first boundary.
161
+ start_idx = body.index(sep)
162
+ return {} if start_idx.nil?
163
+ cursor = start_idx + sep.length
164
+ # consume possible CRLF right after the first boundary
165
+ if body[cursor, 2] == CRLF
166
+ cursor += 2
167
+ end
168
+
169
+ parts = {}
170
+
171
+ loop do
172
+ # Find the next boundary after cursor.
173
+ # Each part ends with CRLF before the next "--boundary" line,
174
+ # or "--boundary--" for the terminator.
175
+ next_sep = body.index(CRLF + sep, cursor)
176
+ break if next_sep.nil?
177
+
178
+ part = body[cursor...next_sep]
179
+
180
+ # Split headers / body on the first blank line (CRLF CRLF).
181
+ headers_end = part.index(CRLF + CRLF)
182
+ if headers_end
183
+ raw_headers = part[0...headers_end]
184
+ raw_body = part[(headers_end + 4)..-1] || ''
185
+ else
186
+ raw_headers = part
187
+ raw_body = ''
188
+ end
189
+
190
+ disposition = nil
191
+ ctype = nil
192
+ raw_headers.split(CRLF).each do |line|
193
+ name, value = line.split(':', 2)
194
+ next if name.nil? || value.nil?
195
+ name = name.strip.downcase
196
+ value = value.strip
197
+ case name
198
+ when 'content-disposition' then disposition = value
199
+ when 'content-type' then ctype = value
200
+ end
201
+ end
202
+
203
+ if disposition
204
+ field_name = extract_disposition_param(disposition, 'name')
205
+ filename = extract_disposition_param(disposition, 'filename')
206
+ if field_name
207
+ if filename && !filename.empty?
208
+ parts[field_name] = UploadedFile.new(
209
+ name: field_name,
210
+ filename: filename,
211
+ content_type: ctype,
212
+ head: raw_headers,
213
+ bytes_binstr: raw_body
214
+ )
215
+ else
216
+ parts[field_name] = raw_body
217
+ end
218
+ end
219
+ end
220
+
221
+ cursor = next_sep + CRLF.length + sep.length
222
+ # Check whether this is the terminator `--boundary--`
223
+ if body[cursor, 2] == '--'
224
+ break
225
+ end
226
+ if body[cursor, 2] == CRLF
227
+ cursor += 2
228
+ end
229
+ end
230
+
231
+ parts
232
+ end
233
+
234
+ # Extract a quoted or bare parameter from a Content-Disposition value.
235
+ # Handles `name="file"; filename="pic.png"` and RFC 5987
236
+ # `filename*=UTF-8''pic.png` (best-effort URL decoding).
237
+ #
238
+ # The `(^|[;\s])` prefix is load-bearing: without it, looking up
239
+ # `name` would also match inside `filename*=...` (substring "name*=")
240
+ # and mis-attribute the filename to the form-field name. RFC 7578
241
+ # places each parameter after `;` (with optional whitespace), so the
242
+ # prefix is free.
243
+ def self.extract_disposition_param(disposition, key)
244
+ k = Regexp.escape(key)
245
+ # filename*=charset'lang'encoded (RFC 5987)
246
+ star_re = /(?:^|[;\s])#{k}\*\s*=\s*([^;]+)/i
247
+ if (m = disposition.match(star_re))
248
+ raw = m[1].strip
249
+ parts = raw.split("'", 3)
250
+ encoded = parts[2] || parts[0]
251
+ return decode_rfc5987(encoded)
252
+ end
253
+ # Quoted `key="value"`
254
+ q_re = /(?:^|[;\s])#{k}\s*=\s*"((?:\\"|[^"])*)"/i
255
+ if (m = disposition.match(q_re))
256
+ return m[1].gsub('\\"', '"')
257
+ end
258
+ # Bare `key=value`
259
+ b_re = /(?:^|[;\s])#{k}\s*=\s*([^;]+)/i
260
+ if (m = disposition.match(b_re))
261
+ return m[1].strip
262
+ end
263
+ nil
264
+ end
265
+
266
+ def self.decode_rfc5987(s)
267
+ `decodeURIComponent(#{s.to_s})`
268
+ rescue StandardError
269
+ s
270
+ end
271
+
272
+ # Rack::Request integration — parse the multipart body once per
273
+ # request, cache on the env, hydrate Sinatra's `params` Hash.
274
+ #
275
+ # Called lazily from our patched Rack::Request#POST.
276
+ def self.rack_params(env)
277
+ cached = env['cloudflare.multipart']
278
+ return cached if cached
279
+
280
+ ct = env['CONTENT_TYPE']
281
+ return ({}) unless ct && ct.to_s.downcase.include?('multipart/')
282
+
283
+ io = env['rack.input']
284
+ return ({}) if io.nil?
285
+
286
+ # `rack.input` is normally a StringIO wrapping the body_binstr
287
+ # we staged in src/worker.mjs. Read the full body; it's already
288
+ # resolved server-side (Workers doesn't stream request bodies
289
+ # back into Opal).
290
+ if io.respond_to?(:rewind)
291
+ begin
292
+ io.rewind
293
+ rescue
294
+ # some stubs don't support rewind — ignore
295
+ end
296
+ end
297
+ body = io.respond_to?(:read) ? io.read.to_s : ''
298
+
299
+ parsed = parse(body, ct)
300
+ env['cloudflare.multipart'] = parsed
301
+ parsed
302
+ end
303
+ end
304
+ end
305
+
306
+ # --------------------------------------------------------------------
307
+ # Rack::Request hook — expose multipart parts via `params[name]`.
308
+ # --------------------------------------------------------------------
309
+ #
310
+ # Sinatra's `params` is the result of `Rack::Request#params`, which
311
+ # merges GET + POST data. `#POST` on the upstream Rack gem uses
312
+ # `Rack::Multipart.extract_multipart` — which fails on Workers (no
313
+ # Tempfile). We reopen Rack::Request and override #POST to use the
314
+ # bespoke Cloudflare::Multipart parser for multipart requests, falling
315
+ # back to the gem implementation for everything else.
316
+
317
+ require 'rack/request'
318
+
319
+ module Rack
320
+ class Request
321
+ alias_method :__homura_original_POST, :POST unless method_defined?(:__homura_original_POST)
322
+
323
+ def POST
324
+ ct = env['CONTENT_TYPE']
325
+ if ct && ct.to_s.downcase.include?('multipart/')
326
+ ::Cloudflare::Multipart.rack_params(env)
327
+ else
328
+ __homura_original_POST
329
+ end
330
+ end
331
+ end
332
+ end