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,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
|