Real Ruby + Real Sinatra on Cloudflare Workers, via Opal.
Live demo: https://homura.kazu-san.workers.dev
Phase 15-F (in progress) — The release units stay in this monorepo, but each gem is published independently from its own gemspec. The three runtime gems (homura-runtime, sinatra-homura, sequel-d1) and the patched Opal fork (opal-homura) all build cleanly as .gem artifacts. Consumer flow is gem install --local (order: opal-homura → runtime → sinatra → sequel-d1), then plain cloudflare-workers-new myapp and bundle exec cloudflare-workers-build. homura itself remains the showcase app / integration repo for now and is not part of the RubyGems publish set. Entry topology is documented in gems/homura-runtime/docs/ARCHITECTURE.md. wrangler.toml main points at build/worker.entrypoint.mjs.
- Machine-readable docs:
/llms.txt(live: https://homura.kazu-san.workers.dev/llms.txt) - Installable agent skill:
skills/homura-workers-gems/
gh skill preview kazuph/homura homura-workers-gems
gh skill install kazuph/homura homura-workers-gems --agent github-copilot --scope user
gh skill install kazuph/homura homura-workers-gems --agent claude-code --scope projectnpx skills add kazuph/homura --skill homura-workers-gems -a github-copilot
npx skills add kazuph/homura --skill homura-workers-gems -a claude-codeThe skill teaches agents how the 4 published gems fit together, which gem to pick for a given task, the install/build flow, and the main Workers/Opal gotchas.
A real Sinatra route, hosted on Cloudflare Workers, talking to Cloudflare
Workers AI through the env.AI binding — wrapped in a tiny Ruby helper so
the route reads like any other Sinatra controller. JWT-protected via the
Phase 8 vendored ruby-jwt. Conversation history persists in Workers KV.
# app/hello.rb (excerpt)
CHAT_MODELS = {
primary: '@cf/google/gemma-4-26b-a4b-it', # Google Gemma 4, 256K ctx, $0.10/$0.30 per Mtok
fallback: '@cf/openai/gpt-oss-120b' # OpenAI gpt-oss-120b, 128K ctx, $0.35/$0.75 per Mtok
}.freeze
post '/api/chat/messages' do
content_type 'application/json'
# ... inline JWT verify (see source) ...
history = load_chat_history(session_id).__await__
result = Cloudflare::AI.run(
model,
{ messages: build_ai_messages(history, user_text), max_tokens: 1024 },
binding: env['cloudflare.AI']
).__await__
reply_text = App.extract_ai_text(result).strip
save_chat_history(session_id, history + [...]).__await__
{ 'ok' => true, 'reply' => reply_text, 'model' => model, 'history_len' => ... }.to_json
end# Live capture (wrangler dev → real Workers AI; full log: .artifacts/phase10-ai/api-evidence.txt)
$ TOKEN=$(curl -s -X POST 'http://127.0.0.1:8788/api/login?alg=HS256' \
-H 'content-type: application/json' \
-d '{"username":"chat-demo","role":"user"}' | jq -r .access_token)
$ curl -s -X POST 'http://127.0.0.1:8788/api/chat/messages' \
-H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{"session":"hero","content":"日本語で50字以内で挨拶+自己紹介",
"model":"@cf/google/gemma-4-26b-a4b-it"}' | jq
{
"ok": true,
"session": "hero",
"model": "@cf/google/gemma-4-26b-a4b-it",
"used_fallback": false,
"elapsed_ms": 3112,
"reply": "こんにちは!homuraです。Sinatra-on-Cloudflare-Workersのフレンドリーな助手です。",
"history_len": 2
}
| binding | what we use | model |
|---|---|---|
env.AI |
text generation, OpenAI-compatible chat completions | @cf/google/gemma-4-26b-a4b-it (primary) · @cf/openai/gpt-oss-120b (fallback) |
env.KV |
per-session chat history (chat:<session>, capped at 32 messages) |
n/a |
| Phase 8 JWT | inline verify against Authorization: Bearer … (HS256 by default) |
n/a |
The chat UI lives at GET /chat (views/chat.erb precompiled by bin/compile-erb).
POST /api/chat/messages, GET /api/chat/messages, DELETE /api/chat/messages
are JWT-gated. /test/ai runs both models against the live Workers AI catalog
as a "CI on Workers" smoke check; /test/ai/debug dumps the raw Workers AI
response so you can spot model-specific schema drift.
Llama-family models intentionally excluded. Phase 10 ships Gemma 4 + gpt-oss-120b only; see
docs/ROADMAP.mdfor the rationale.
# app/hello.rb — literal Sinatra DSL, no Cloudflare imports, no backtick JS.
require 'sinatra/base'
class App < Sinatra::Base
get '/' do
@title = 'Hello from Sinatra'
@users = env['cloudflare.DB'].prepare('SELECT id, name FROM users').all.__await__
@content = erb :index # ← real ERB, precompiled at build time
erb :layout
end
get '/hello/:name' do
@name = params['name']
@content = erb :hello
erb :layout
end
get '/d1/users' do
content_type 'application/json'
env['cloudflare.DB'].prepare('SELECT id, name FROM users').all.__await__.to_json
end
end
run Appviews/index.erb, views/layout.erb, etc. are plain ERB. bin/compile-erb
translates them to Ruby methods at build time, so the Workers sandbox
never sees an eval / new Function at request time.
That file is compiled by Opal to a 580 KiB (gzip) ESM Module Worker and deployed straight to Cloudflare's edge. The Sinatra routes call D1, KV, and R2 bindings through a thin Ruby wrapper that exposes each Cloudflare JavaScript object as a plain Ruby value.
Sister project: kazuph/hinoko —
the mruby/WASI version, with a custom Hono-like DSL. homura is the
opposite bet: no DSL, real Sinatra, real Rack, real middleware chain.
- Why this exists
- Architecture at a glance
- Upstream sources (everything is real)
- Applied patches, file by file
- vendor/opal-gem — Opal compiler & corelib
- vendor/sinatra — janbiedermann/sinatra fork
- vendor/rack, vendor/mustermann, vendor/rack/protection
- vendor/*.rb stubs (Digest, Zlib, Tempfile, Tilt, …)
- lib/opal_patches.rb — runtime shims loaded before user code
- lib/cloudflare_workers.rb — Rack handler & D1/KV/R2 wrappers
- src/worker.mjs — Module Worker entry
- bin/compile-erb + views/ — build-time ERB precompiler
- Build & run
- Directory layout
- Net::HTTP works (Phase 6)
- Crypto works (Phase 7)
- JWT 認証 (Phase 8)
- Scheduled Workers — Cron Triggers (Phase 9)
- Project status & phases
- Strict no-fallback policy
- License
Every "Ruby on Cloudflare Workers" post I could find uses mruby, ruby.wasm, or invents a small DSL and dresses it up as Sinatra. The Ruby people actually work with — Sinatra, ERB templates, Rack middleware — never shows up on Workers, because the toolchain to put it there doesn't exist.
homura is the brute-force answer to the question "what does it
actually take to run the real Sinatra, the real Rack, the real
Rack::Protection, on Cloudflare Workers?" The answer turns out to be:
- Vendor Opal, fix three compiler/runtime bugs that Sinatra exercises.
- Vendor
janbiedermann/sinatra(the CRuby-3-compatible fork), fix a handful of places where Sinatra assumes mutable strings or CRuby-specific semantics that differ on Opal/V8. - Build an Opal ↔ Cloudflare Rack adapter (
lib/cloudflare_workers.rb) that makes Sinatra think it's running on Puma. - Bridge sync Sinatra to async Cloudflare bindings (D1 / KV / R2) using
Opal's
# await: truemagic-comment support plus aPromise-aware response builder.
Every patch is listed below with a one-line rationale so you can read
the diff against upstream by rg "homura patch".
┌─────────────────────────────────────────────┐
│ Cloudflare Workers (V8) │
│ │
JS fetch ─┼─▶ src/worker.mjs │
event │ • awaits req.text() │
│ • forwards to globalThis. │
│ __HOMURA_RACK_DISPATCH__ │
│ │
│ async function dispatcher │
│ └─▶ Rack::Handler::CloudflareWorkers │
│ • build_rack_env │
│ • env['cloudflare.DB/KV/BUCKET'] │
│ = Ruby wrappers over CF bindings │
│ │
│ @app.call(env) │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────┐ │
│ │ Sinatra middleware stack │ │
│ │ (real janbiedermann/sinatra) │ │
│ │ │ │
│ │ ExtendedRack → ShowExceptions → │ │
│ │ Head → NullLogger → │ │
│ │ Rack::Protection::* → App │ │
│ └───────────────────────────────────┘ │
│ │ │
│ ▼ │
│ user Sinatra route │
│ (app/hello.rb, pure Ruby) │
│ │
│ • sync route → plain Rack tuple │
│ • async route (`# await: true`) → │
│ Promise body, awaited in │
│ build_js_response before returning │
│ │
└─────────────────────────────────────────────┘
│
▼
new Response(body, …)
Opal compiles once at build time (bundle exec opal -c …). The
output is a single 4 MiB ESM (≈580 KiB gzip) that contains the Opal
runtime, the Ruby corelib, janbiedermann/sinatra + patched Rack +
Mustermann + Rack::Protection, homura's adapter, and the user's
app/hello.rb. Cloudflare Workers imports that ESM from
src/worker.mjs, wrapped in a ~30 line Module Worker fetch handler.
Nothing in this repo is a clean-room reimplementation or a fake
"Sinatra-like" DSL. Every framework is the actual upstream gem, pulled
in-tree, patched only when Opal or the Workers sandbox forces the
issue. The patch sites are all marked with a # homura patch:
comment.
| Upstream | Version vendored | Path in this repo | Upstream URL |
|---|---|---|---|
| Opal (Ruby→JS compiler + corelib + stdlib) | 1.8.3.rc1 |
vendor/opal-gem/ |
https://github.com/opal/opal |
| janbiedermann/sinatra (CRuby-3 compatible fork) | fork HEAD when vendored | vendor/sinatra/ |
https://github.com/janbiedermann/sinatra |
| Rack (patched copy shipped by the janbiedermann fork) | same | vendor/rack/ |
https://github.com/rack/rack |
| Mustermann (Sinatra's path matcher) | same | vendor/mustermann/ |
https://github.com/sinatra/mustermann |
| Rack::Protection | same | vendor/rack/protection/ |
https://github.com/sinatra/sinatra/tree/main/rack-protection |
Gemfile pins Opal via gem 'opal-homura', path: 'vendor/opal-gem', require: 'opal', so the
vendored copy is what actually compiles every build.
vendor/opal-gem/ was copied from the official opal-1.8.3.rc1
release. The rest of vendor/ was copied from the janbiedermann/sinatra
fork's own vendored gems (the fork already ships patched Rack +
Mustermann + Rack::Protection to work with modern Ruby).
Every patch is additive (nothing is deleted from upstream). The "why" column is the single-sentence rationale — most of them have a longer inline comment at the patch site.
| File | Patch | Why |
|---|---|---|
lib/opal/nodes/literal.rb |
extract_flags_and_value rewrites \A → ^, \Z/\z → $ in dstr (interpolated) regex literals, not just static ones. |
Opal only normalised anchors when the regex literal had no #{} interpolation. /\A#{inner}\Z/ — Mustermann's route pattern shape — shipped to V8 with literal \A\Z, which JS regex silently treats as the letters A/Z, so every Sinatra route failed to match its own path. Fix normalises each :str child inside the dstr. |
opal/corelib/error.rb |
UncaughtThrowError now inherits from ::Exception instead of ::ArgumentError (i.e. no longer a StandardError). |
Opal's throw/catch is implemented as raise/rescue of UncaughtThrowError. With the old parent chain, every rescue StandardError frame on the stack swallowed in-flight throws — Sinatra's process_route has exactly that frame, so throw :halt, body never reached invoke's catch(:halt). Re-parenting to Exception matches MRI's observable behaviour (MRI uses longjmp, so rescue StandardError never sees a mid-flight throw). |
lib/opal/nodes/closure.rb |
NextNode/thrower(:next, value) now emits the value expression as a side-effecting JS statement before continue when the thrower closure is LOOP_INSIDE. |
Upstream just pushed a bare continue and silently dropped the argument. next base = base.superclass in Sinatra's error_block! therefore never assigned to the enclosing base — the loop spun forever as soon as a request hit an un-handled status code. Emitting the expression first matches CRuby semantics. |
opal/corelib/runtime.js |
Added explicit prototype property on the generated class constructors. |
Required for @prototype collision fix that made Sinatra::Base.allocate return a well-formed instance instead of nil. Without this, every Sinatra::Base.new fell over at the first JS-level constructor mismatch. |
lib/opal/rewriters/js_reserved_words.rb |
Added prototype to the reserved-words rewriter. |
Follow-up to the runtime.js fix: Ruby methods named prototype now get mangled so they don't clobber the JS-level $$prototype chain. |
opal/corelib/regexp.rb |
Multi-replace in JS regex replacement helpers. | Opal's gsub/sub helper only replaced the first match in a few edge cases. |
| File | Patch | Why |
|---|---|---|
base.rb Sinatra::Delegator.delegate |
Removed the super(*args, &block) if respond_to? branch inside define_method. |
Opal's compiled super inside define_method hard-codes the enclosing Ruby method name ('delegate') instead of the dynamically-defined one, so the call resolved to the wrong method. Upstream relies on method_missing-style dispatch that Opal doesn't emit. |
base.rb Sinatra::Base.new! |
Rewritten from alias new! new to an explicit def new!(*args, &block); allocate; send(:initialize, *args, &block); end. |
Opal drops alias inside class << self. Without the rewrite, new! was undefined. |
base.rb Sinatra::Base#content_type |
mime_type << … → mime_type += …. |
Opal Strings are immutable (they're JS Strings). << raises NotImplementedError as soon as a route calls content_type 'application/json'. |
base.rb Sinatra::Base.force_encoding |
Drop the trailing .encode! from data.force_encoding(encoding).encode!. |
force_encoding works on Opal (returns a re-tagged copy) but encode! raises. Opal Strings are already canonical UTF on JS, so the transcode is observably a no-op. |
base.rb Sinatra::Base#invoke |
When catch(:halt) yields a JS-level Promise (detected via Cloudflare.js_promise?), stash it as a single-chunk body so the adapter can await it later. |
Async route blocks compiled with # await: true return a Promise synchronously. Without this patch invoke's body-detection falls through and @response.body stays empty. We use a true typeof obj.then === 'function' check instead of respond_to?(:then) because Kernel#then (Ruby 2.6+) is defined on every object and would poison the body array with false from error_block!. |
base.rb Sinatra::Response#calculate_content_length? |
Skip the bytesize loop whenever a body chunk is a pending JS Promise. | Content-length can't be computed before the Promise resolves; the adapter will build the header after Promise.all settles. |
| File | Patch | Why |
|---|---|---|
vendor/rack.rb |
autoload :Lint commented out. |
Opal's parser chokes on rack/lint.rb's /[\x80-\xff]/ regex literal. We don't run Lint on Workers anyway. |
vendor/rack/utils.rb |
URI_PARSER = CGI-backed Module.new. |
Opal's uri stdlib doesn't define URI::DEFAULT_PARSER. |
vendor/rack/request.rb |
Three regex rewrites (trusted_proxies union, ipv6 union, AUTHORITY alternation). |
JS regex doesn't support (?i) inside a union alongside non-/i members, doesn't support (?x) (extended) mode, and rejects duplicate named captures in alternation. Each regex is hand-folded so the compiled JS regex is accepted by V8 without changing the match semantics. |
vendor/rack/show_exceptions.rb #pretty |
Rewritten as a plain Ruby string builder instead of template.result(binding). |
Upstream uses ERB, which compiles to code that runs via binding.eval → new Function($code). Cloudflare Workers refuses new Function with "Code generation from strings disallowed for this context", so the entire dev-mode error page exploded with a second exception inside the renderer. The hand-rolled version produces the same rescue page (title, traceback, request info, env dump) without ever touching ERB or binding.eval. |
vendor/rack/media_type.rb type / params |
rstrip! / downcase! / strip! → non-mutating counterparts. |
Immutable-Opal-String rule again. Surfaces on the first POST request via Sinatra::Helpers#form_data?. |
vendor/rack/builder.rb |
to_app's inject fold rewritten as an index-based wrap_middleware_chain helper. |
Opal's compiled each block doesn't propagate a closure-captured accumulator through the inject chain; the last middleware's app was always nil. |
vendor/rack/show_exceptions.rb CSS block |
string << … → string += …. |
Same immutable-String rule. |
vendor/mustermann/ast/parser.rb |
read_brackets, read_list, read_escaped use result += … / explicit result[-1] = result.last + … instead of String#<<. |
Same reason. |
vendor/mustermann/ast/node.rb |
Node#parse detects a String-typed payload (Capture#parse initialises @payload = String.new and then super's while loop does payload << element) and uses reassignment. |
Same reason. |
Opal's stdlib doesn't ship these gems, but Sinatra / Rack / Rack::Protection
reference them at class-body time. They're vendored as minimal shape
stubs — constants exist, methods raise NotImplementedError (for the
ones we don't need) — so the require chain succeeds.
| File | Purpose |
|---|---|
vendor/digest.rb, vendor/digest/sha2.rb |
Digest::Class, Digest::Base, Digest::SHA1, Digest::MD5, Digest::SHA256/384/512 constants |
vendor/zlib.rb |
Zlib::* constants; method calls raise |
vendor/cgi/escape.rb |
require 'cgi' re-export shim |
vendor/tempfile.rb |
StringIO-backed stub |
vendor/tilt.rb |
Enough surface for Tilt.default_mapping.extensions_for(...) / Tilt[engine] at class-body time |
Opal gets -r opal_patches on the command line, so this file is the
first thing to run after the corelib. It patches in everything
Sinatra / Rack / Mustermann / Rack::Protection assume but Opal's
corelib doesn't ship.
| Patch | Why |
|---|---|
Module#deprecate_constant no-op |
CRuby 2.6+ ships it; Opal doesn't. rack/multipart/parser.rb calls it at class-body time. |
Module#const_defined? qualified name walker |
Opal's built-in rejects "Foo::Bar::Baz". Mustermann's Node[:root] factory needs it. |
Forwardable#def_instance_delegator dot-path accessor |
Mustermann uses instance_delegate %i[parser compiler] => 'self.class'. Upstream Opal resolves 'self.class' via instance_eval(String), which compiles to new Function($code) — forbidden on Workers. The patch replaces that with a small dot-path walker (self, self.class, @ivar.foo, plain method name) that never touches eval. |
30+ Encoding::* aliases (ISO_2022_JP, SHIFT_JIS, EUC_JP, WINDOWS_1252, …) |
Opal only ships UTF-8/16/32, ASCII-8BIT, ISO-8859-1, US-ASCII. Rack gems reference many more legacy encodings in constant hashes at class-body time. Each missing name is aliased to Encoding::ASCII_8BIT so the constant reference succeeds; a real .encode call still raises clearly. |
URI::DEFAULT_PARSER / URI::RFC2396_PARSER / URI::Parser |
CGI-backed module with the same escape / unescape / regexp[:UNSAFE] surface that mustermann/ast/translator.rb and rack/utils.rb actually call. |
URI.parse / URI::InvalidURIError / URI::Error / URI.decode_www_form_component / URI.encode_www_form_component |
Phase 2 2nd-pass + Phase 3: Rack::Protection::JsonCsrf and Rack::Utils need these. URI.parse is backed by the JS URL constructor; the rest defer to CGI. |
$0 / $PROGRAM_NAME default |
sinatra/main.rb has proc { File.expand_path($0) } at class-body time; Opal leaves both nil, which crashes. |
IO.read / File.read / File.binread / File.fnmatch |
Raise Errno::ENOENT. Sinatra's inline_templates= wraps File.open in rescue ENOENT — we need the right exception type so the rescue takes the silent path. |
SecureRandom.hex / random_bytes / uuid / base64 |
Backed by Web Crypto API (crypto.getRandomValues). CF Workers forbids random-value generation at global scope (module init), so the implementation catches that failure and falls back to a deterministic zero string — same degradation CRuby itself does when SecureRandom is unavailable. |
Eager require 'digest' / 'zlib' / 'tempfile' / 'tilt' |
Some gems reference Digest::SHA1 at class-body time without an explicit require 'digest'. |
This is the only file in the entire codebase that knows it's running on Cloudflare Workers. Everything above it in the stack (Sinatra, Rack, Mustermann, user code) thinks it's running under a normal Rack server.
Three responsibilities:
-
CloudflareWorkersIO— replaces$stdout/$stderrwith shims that route Rubyputs/printto V8'sglobalThis.console.log/error(Opal's defaultnodejs.rbadapter tries to write to a closed Socket on Workers). -
Rack::Handler::CloudflareWorkers— standard Rack handler convention (a module with arunclass method). Converts each CF WorkersRequestinto a Rack env Hash, calls@app.call(env), turns the[status, headers, body]tuple back into a JSResponse.build_rack_envspec-compliant with Rack SPEC: setsREQUEST_METHOD,PATH_INFO,QUERY_STRING,rack.input(aStringIObuilt from the body textworker.mjsawaited for us),rack.errors,HTTP_*, etc. The CF bindings are injected under the Rack conventioncloudflare.env/cloudflare.ctx, plus convenience wrapper keyscloudflare.DB/cloudflare.KV/cloudflare.BUCKET. -
Cloudflare::D1Database/D1Statement/KVNamespace/R2Bucket— tiny Ruby wrappers over the JS bindings. Each mutating method returns the raw JS Promise produced by the binding, optionally wrapped in a.thenthat converts JS results into Ruby Hashes/Arrays so user code can just call.to_jsonon the awaited value.Cloudflare.js_promise?is a nativetypeof obj.then === 'function'check (notrespond_to?(:then), which matches every Ruby object since 2.6).
The dispatcher installed on globalThis.__HOMURA_RACK_DISPATCH__ is
an async function that awaits the Ruby $call result, so both
sync (plain Response) and async (Promise) paths flow through
the same entry point.
30 lines. Imports the compiled ESM, reads req.text() up front (so
the synchronous Opal dispatcher can stuff the body into rack.input
without needing to await anything later), and forwards to
__HOMURA_RACK_DISPATCH__. Skips the body read for GET/HEAD/OPTIONS
so the hot path doesn't pay the cost.
Cloudflare Workers refuses eval and new Function, which is exactly
what stock ERB runs at template.result(binding) time. A normal
Sinatra application would crash on the first erb :index.
homura's answer is to precompile every ERB template to a plain Ruby method at build time, in CRuby, before Opal runs. The pipeline:
- Templates live in
views/*.erbas ordinary ERB source. bin/compile-erb(a ~200 line CRuby script) tokenises each file looking for<% %>/<%= %>/<%== %>/<%# %>tags, emits a Ruby method body that concatenates the result into a local_outvariable using_out = _out + …(Opal Strings are immutable; stock ERB's generated<<would blow up), and writes the whole thing tobuild/homura_templates.rb.- That file registers each template with a
HomuraTemplatesmodule (HomuraTemplates.register(:index) do |locals| … end) and reopensSinatra::Templatesto overrideerb(name, ...)so user code'serb :indextransparently dispatches to the precompiled Proc viainstance.instance_exec(locals, &body). - The Opal build command picks the generated file up with
-I build -r homura_templates, so it runs at Worker init time, installs the override, and is ready before the first request.
Result: user code writes stock Sinatra (erb :index with @ivars and
<%= … %> expressions), never knows Cloudflare's sandbox banned
eval, and still gets <% @users.each do |user| %>…<% end %> loops —
because every ERB tag is just Ruby that Opal compiled to JS ahead of
time. The generated build/homura_templates.rb is idempotent: run
bin/compile-erb any time you change a view.
The layout pattern works too: a route sets @content = erb :index
and then erb :layout, which is the canonical Sinatra two-file
rendering idiom.
mise (or rbenv) — Ruby 3.4.9
mise (or nvm) — Node 22.21.1
Cloudflare account — wrangler CLI logged in
Everything below assumes you run it from the repo root.
bundle install --path vendor/bundleThis pulls Opal from the vendored path in Gemfile
(gem 'opal-homura', path: 'vendor/opal-gem', require: 'opal') so there's no pre-release
download to worry about.
npm run buildThis runs three steps in sequence:
npm run build:erb—ruby bin/compile-erbscansviews/*.erband writesbuild/homura_templates.rb.npm run build:assets—ruby bin/compile-assetsembedspublic/*(CSS, SVG — NOT binary images, which go through R2) intobuild/homura_assets.rb.npm run build:opal—bundle exec opal -c -E --esm …compiles everything intobuild/hello.no-exit.mjswith the full flag set:-I lib -I vendor -I build,-r opal_patches -r cloudflare_workers -r homura_templates -r homura_assets.
All three generated files live under build/ (gitignored). Running
npm run build is the only build command you need to remember.
npm run dev
# → builds, then starts wrangler dev on http://127.0.0.1:8787npm run deploy
# → builds, then runs wrangler deploywrangler.toml declares the [[d1_databases]] / [[kv_namespaces]]
/ [[r2_buckets]] bindings; the deploy log prints them back.
npx wrangler d1 create homura-db
npx wrangler kv namespace create homura-kv
npx wrangler r2 bucket create homura-bucket
# seed a users table for the D1 demo routes
npx wrangler d1 execute homura-db --remote --command \
"CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL);"
npx wrangler d1 execute homura-db --remote --command \
"INSERT INTO users (name) VALUES ('alice'), ('bob'), ('kazu');"Update wrangler.toml with the database_id / KV namespace id that
wrangler prints out.
homura/
├── app/
│ └── hello.rb ← User Sinatra app. Plain Ruby, no CF imports.
├── views/
│ ├── layout.erb ← Top-level HTML skeleton (<nav>, <footer>, …)
│ ├── index.erb ← Home page (reads @users from D1)
│ ├── hello.erb ← /hello/:name — classic <%= @name %>
│ └── about.erb ← /about
├── bin/
│ └── compile-erb ← Ruby script: views/*.erb → build/homura_templates.rb
├── build/
│ ├── hello.no-exit.mjs ← Opal output (≈4 MiB, ≈580 KiB gzip). Gitignored.
│ └── homura_templates.rb ← Auto-generated from views/. Picked up via -I build.
├── lib/
│ ├── cloudflare_workers.rb ← Rack handler + CF binding wrappers. The only
│ │ file that knows it's on Workers.
│ └── opal_patches.rb ← Runtime shims (Module#deprecate_constant, URI,
│ Forwardable dot-path, encodings, SecureRandom).
├── src/
│ └── worker.mjs ← 30-line Module Worker fetch handler.
├── vendor/
│ ├── opal-gem/ ← Opal 1.8.3.rc1 (full gem, 3 homura patches).
│ ├── sinatra/ ← janbiedermann/sinatra fork. 6 homura patches
│ │ in base.rb.
│ ├── rack/ ← Rack + Rack::Protection (patched).
│ ├── mustermann/ ← Mustermann (patched).
│ ├── digest.rb, zlib.rb, ← Minimal stubs for corelib gaps.
│ │ tempfile.rb, tilt.rb
│ └── bundle/ ← bundler install target. Gitignored.
├── wrangler.toml ← Worker config with D1 / KV / R2 bindings.
├── Gemfile ← Pins Opal via path: 'vendor/opal-gem'.
└── .artifacts/homura/ ← Not tracked. Holds the strict PLAN.md and
per-phase evidence logs (phase 2 / phase 3).
Every patch file in vendor/** has an inline # homura patch:
comment, so the complete diff against upstream is:
rg "homura patch" vendor libThe Phase 6 patch added Cloudflare::HTTP.fetch (a thin Ruby wrapper
around globalThis.fetch) and a Net::HTTP shim that delegates to it.
Existing Ruby code that uses Net::HTTP.get(URI('...')) works
unchanged inside Sinatra routes — the only addition required is the
.__await__ suffix that already appears around D1/KV/R2 calls.
get '/demo/http' do
content_type 'application/json'
res = Net::HTTP.get_response(URI('https://api.ipify.org/?format=json')).__await__
{
'status' => res.code,
'content_type' => res['content-type'],
'body' => JSON.parse(res.body)
}.to_json
endWhat's covered:
Cloudflare::HTTP.fetch(url, method:, headers:, body:)→Cloudflare::HTTPResponsewithstatus/headers(lowercased Hash) /body(String) /json/ok?/[].Net::HTTP.get(uri)→ body String.Net::HTTP.get_response(uri)→Net::HTTPResponsewithbody/code/message/[]/each_header.Net::HTTP.post_form(uri, hash)→ urlencoded POST returningNet::HTTPResponse.Kernel#URI('https://...')shorthand forURI.parse(...).
What's not covered (raw TCP is impossible on Workers): persistent
connections, Net::HTTP.start-style block forms, request objects, raw
socket access, multipart upload, chunked streaming bodies. Use
Cloudflare::HTTP.fetch directly for those — it accepts arbitrary
fetch init options through headers: / method: / body:.
Smoke tests live in test/http_smoke.rb and run as part of npm test.
Phase 7 fills in the crypto stubs so unmodified Ruby crypto code runs on the edge. Two backends, picked per-API based on what's actually implemented in the Workers runtime:
| Backend | What it covers |
|---|---|
node:crypto (sync, via nodejs_compat) |
Digest::SHA1/256/384/512/MD5, OpenSSL::HMAC (5 algos), OpenSSL::KDF (PBKDF2 / HKDF), OpenSSL::PKey::RSA / EC / Ed25519 / X25519 key generation + PEM I/O, SecureRandom, OpenSSL::BN (BigInt-backed) |
Web Crypto subtle (async, via globalThis.crypto.subtle) |
OpenSSL::Cipher (AES-GCM / CBC / CTR), RSA sign / verify (RS256/384/512), RSA sign_pss / verify_pss (PS256/384/512), RSA public_encrypt / private_decrypt (RSA-OAEP), EC sign / verify (ES256/384/512, **DER + raw R |
Workers' nodejs_compat layer (unenv) doesn't currently implement
createCipheriv / createSign / createVerify. Anything that needs
those goes through subtle.* instead, which is async, so callers
add .__await__ exactly like with D1 / KV / R2 / Cloudflare::HTTP.fetch.
- Hashes / HMAC / KDF: SHA-1/256/384/512, MD5, HMAC with each, PBKDF2-HMAC, HKDF.
- JWT signing / verification: HS256/384/512, RS256/384/512,
PS256/384/512, ES256/384/512 (both DER and raw-R||S
formats — the JWT-compatible raw form is
sign_jwt/verify_jwt), EdDSA (Ed25519). - AEAD: AES-128/192/256-GCM with
auth_tag+auth_data, tampering rejection, AAD-mismatch rejection, full byte-transparent plaintext (every value 0x00..0xff round-trips). - AES-CBC: AES-128/192/256-CBC with PKCS#7 padding.
- AES-CTR: AES-128/192/256-CTR with true streaming —
update(chunk)returns ciphertext for whole 16-byte blocks immediately, with the tail carried forward and the counter incremented per call. - RSA-OAEP:
public_encrypt/private_decrypt, default SHA-256, alternate hashes viahash:argument. - ECDH: P-256 / P-384 / P-521 key agreement.
- X25519: Curve25519 ECDH key agreement.
- PEM I/O: SPKI public, PKCS#8 private; round-trip preserves the underlying KeyObject.
- OpenSSL::BN: BigInt-backed
+/-/*///%/**, comparison,gcd,mod_exp,num_bits,to_s(radix), etc. - SecureRandom:
hex/random_bytes/urlsafe_base64/uuid, all backed bynode:crypto.randomBytes.
- ChaCha20-Poly1305: not in the Web Crypto spec and not
implemented in
nodejs_compat. Would require a vendored pure-JS AEAD implementation, deferred. - CBC streaming
updatemid-block: subtle AES-CBC enforces PKCS#7 padding atomically.updatebuffers andfinalemits the full ciphertext. Drop down to AES-CTR if you need true streaming. - PKCS#1 v1.5 RSA encrypt/decrypt (legacy): subtle only exposes RSA-OAEP for encryption. Use OAEP (the modern default).
- ECDSA with HMAC, RSA key generation < 2048 bits: not exposed by subtle.
Methods that go through subtle return JS Promises. Inside
# await: true Ruby files (Sinatra route bodies, helpers, smoke
tests), append .__await__ exactly like with D1 / KV / R2:
# Synchronous (node:crypto)
Digest::SHA256.hexdigest('hello')
OpenSSL::HMAC.hexdigest('SHA256', 'secret', 'hello')
OpenSSL::KDF.pbkdf2_hmac('pw', salt: 's', iterations: 4096, length: 32, hash: 'SHA256')
SecureRandom.hex(16)
rsa = OpenSSL::PKey::RSA.new(2048)
ec = OpenSSL::PKey::EC.generate('prime256v1')
rsa.to_pem; OpenSSL::PKey::RSA.new(rsa.to_pem)
OpenSSL::BN.new(3).mod_exp(5, 13) # → 9
# Async (Web Crypto subtle, requires .__await__)
cip = OpenSSL::Cipher.new('AES-256-GCM').encrypt
cip.key = key; cip.iv = iv
cip.update(plain)
ct = cip.final.__await__
tag = cip.auth_tag
# RSA-PSS (JWT PS256)
sig = rsa.sign_pss('SHA256', msg, salt_length: :digest, mgf1_hash: 'SHA256').__await__
ok = rsa.public_key.verify_pss('SHA256', sig, msg, salt_length: :digest, mgf1_hash: 'SHA256').__await__
# RSA-OAEP encrypt/decrypt
ct = rsa.public_key.public_encrypt('payload').__await__
recovered = rsa.private_decrypt(ct).__await__
# ECDSA — DER (CRuby compat) and raw-R||S (JWT compat)
der = ec.sign(OpenSSL::Digest::SHA256.new, msg).__await__ # DER
raw = ec.sign_jwt(OpenSSL::Digest::SHA256.new, msg).__await__ # raw R||S
# ECDH
shared = alice_ec.dh_compute_key(bob_ec).__await__
# Ed25519 / X25519
ed_sig = ed_key.sign(nil, msg).__await__
shared = alice_x.dh_compute_key(bob_x).__await__npm run test:crypto— 85 smoke tests against CRuby-reference values for SHA / HMAC / KDF, AES round-trips with tampering detection, RSA RS / PS / OAEP, ECDSA ES256/384/512 (both DER and raw), ECDH P-256/384/521, Ed25519, X25519, BN arithmetic.npm test— full suite, 96 tests (27 smoke + 14 http + 85 crypto = wait, 27 + 14 + 85 = 126 tests; the actual count is whatevernpm testreports — see CI output).npm run test:workers— hits the live/test/cryptoendpoint on a runningwrangler dev(or remote) and confirms every primitive round-trips on the actual Workers runtime, not just on the Node test runner.
A demo route lives at GET /demo/crypto and the self-test endpoint
at GET /test/crypto (both work via npm run dev).
Phase 8 は real jwt gem (ruby-jwt v2.9.3) を vendor して Cloudflare Workers
上で動かす フェーズ。7 つの JWT アルゴリズム全てを Workers ランタイム上で
発行・検証でき、Sinatra の薄いヘルパを被せて 1 行で authenticate!
できる。
| アルゴリズム | 署名バックエンド | プラットフォーム | 同期性 |
|---|---|---|---|
| HS256 / HS384 / HS512 | node:crypto.createHmac (nodejs_compat) |
sync | 追加 .__await__ 不要 |
| RS256 / RS384 / RS512 | Web Crypto subtle.sign('RSASSA-PKCS1-v1_5') |
async | caller が .__await__ |
| PS256 / PS384 / PS512 | Web Crypto subtle.sign('RSA-PSS', saltLength: digest) |
async | caller が .__await__ |
| ES256 / ES384 / ES512 | Web Crypto subtle.sign('ECDSA') (raw R‖S そのまま) |
async | caller が .__await__ |
| EdDSA (= ED25519) | Web Crypto subtle.sign('Ed25519') |
async | caller が .__await__ |
vendor/jwt.rb ← require 'jwt' のエントリ(ruby-jwt 互換)
vendor/jwt/
├── base64.rb ← url_encode / url_decode(Opal 対応、padding 手動)
├── encode.rb ← # await: true、sign() Promise を内部で unwrap
├── decode.rb ← # await: true、any? を while ループに置換
├── jwa/
│ ├── hmac.rb ← HS256/384/512(sync)— secure_compare を hex 正規化
│ ├── rsa.rb ← RS256/384/512(subtle, .__await__)
│ ├── ps.rb ← PS256/384/512(subtle, salt_length: :digest 固定)
│ ├── ecdsa.rb ← ES256/384/512(sign_jwt / verify_jwt = raw R‖S)
│ └── eddsa.rb ← Ed25519 置き換え版(RbNaCl 依存を除去)
├── jwk.rb ← JWKS は Phase 8 非対応。呼ぶとエラー(明示)
├── claims.rb, claims/*.rb ← exp / nbf / iss / aud / sub / jti / iat / required
└── configuration/*.rb ← decode 既定値(verify_expiration 等)
lib/sinatra/jwt_auth.rb ← Sinatra::JwtAuth 拡張
test/jwt_smoke.rb ← 43 ケース(各 alg encode/decode + tamper 拒否 + claims)
require 'sinatra/base'
require 'sinatra/jwt_auth'
class App < Sinatra::Base
register Sinatra::JwtAuth
set :jwt_secret, 'super-secret'
set :jwt_algorithm, 'HS256'
get '/api/me' do
authenticate! # 401 を自動 halt(missing/expired/tampered)
content_type 'application/json'
{ 'user' => current_user }.to_json # current_user は payload Hash
end
post '/api/login' do
token = issue_token({ 'sub' => 'alice', 'role' => 'admin' }, expires_in: 3600)
content_type 'application/json'
{ 'access_token' => token }.to_json
end
end非対称鍵アルゴリズム(RS/PS/ES/EdDSA)を使う場合は署名鍵と検証鍵を 別々に設定する:
private_key = OpenSSL::PKey::EC.generate('prime256v1')
set :jwt_sign_key, private_key
set :jwt_verify_key, private_key # EC は秘密鍵から公開鍵を取れるので同じでOK
set :jwt_algorithm, 'ES256'POST /api/login?alg=<name> は 7 つのアルゴリズム全てで JWT を発行し、
GET /api/me は token の header から alg を自動検出して検証する:
$ curl -X POST http://127.0.0.1:8787/api/login?alg=ES256 \
-H 'content-type: application/json' \
-d '{"username":"alice","role":"admin"}'
{"access_token":"eyJhbGciOi...","refresh_token":"kaj4akI0p3dL7tP6KMbXU7FmnfEeho...","alg":"ES256",...}
$ curl -H "Authorization: Bearer eyJhbGciOi..." http://127.0.0.1:8787/api/me
{"current_user":"alice","role":"admin","alg":"ES256","claims":{...}}
$ curl -X POST http://127.0.0.1:8787/api/login/refresh \
-H 'content-type: application/json' \
-d '{"refresh_token":"kaj4akI0p3dL7tP6KMbXU7FmnfEeho..."}'
{"access_token":"eyJhbGci...(new)...","alg":"HS256","expires_in":3600,...}
refresh_tokenは 48-byte urlsafe base64 で、KV にオパーク文字列として 保持(refresh:<token>キー)。アクセストークンが漏れてもJWT_ACCESS_TTL= 3600 秒で失効するが、リフレッシュトークンは KV に残っているので 再認証不要で新しいアクセストークンを貰える。- 有効期限切れのリフレッシュトークンは KV から削除される。
- 改竄された署名は全アルゴリズムで
JWT::VerificationErrorを投げ、authenticate!が 401 を返す。
| ファイル | パッチ | 理由 |
|---|---|---|
vendor/jwt.rb, vendor/jwt/encode.rb, vendor/jwt/decode.rb |
# await: true + JWT.encode / JWT.decode 公開面に .__await__ |
署名 API が Promise を返す(RS/PS/ES/EdDSA)ため、呼び出し側が同期的に使えるよう await を内部で解決 |
vendor/jwt/jwa/hmac.rb SecurityUtils.secure_compare |
a.unpack1('H*') == b.unpack1('H*') で hex 正規化比較 |
Array#pack('H*') と Base64.urlsafe_decode64 がバイト同値でも bytesize を別々に返すため、upstream の a.bytesize == b.bytesize 前ガードが常に false を返していた |
vendor/jwt/jwa/rsa.rb / ps.rb |
.__await__ 付与、PSS は salt_length: :digest 固定 |
Web Crypto subtle は :auto salt を表現できない |
vendor/jwt/jwa/ecdsa.rb |
OpenSSL::PKey::EC#sign_jwt / #verify_jwt に差し替え(raw R‖S) |
subtle は ECDSA で raw R‖S をそのまま返す — JWT スペックと一致。upstream の DER↔raw 変換ロジックと OpenSSL::ASN1 依存を丸ごと回避 |
vendor/jwt/jwa/eddsa.rb |
RbNaCl 依存を削除し OpenSSL::PKey::Ed25519 で置換、無条件ロード |
Workers に libsodium はない。Phase 7 で subtle.sign('Ed25519') を EdDSA として実装済み |
vendor/jwt/decode.rb verify_signature_for? / verify_signature |
Array#any? を while ループに置換 |
any? のブロックは JS で同期評価される — Promise を返してもそのまま truthy と判定され、実質バイパスされてしまう |
vendor/jwt/decode.rb decode_segments |
verify_signature.__await__ を明示 |
verify_signature は内部で .__await__ を呼ぶので async 関数。呼び出し側で await しないと未処理 Promise rejection が発生し、検証失敗を検出できない |
vendor/jwt/base64.rb url_decode |
padding を手動で補填して urlsafe_decode64 に渡す |
Opal base64 は padding 必須、upstream の padding: false オプションは未対応 |
vendor/jwt/jwk.rb |
全面的にスタブ、呼ぶと JWKError |
JWKS / kid 解決は OpenSSL::PKey の JWK シリアライザが必要 — Phase 8 スコープ外 |
vendor/jwt/jwa.rb |
require 'rbnacl' ブロックを削除、jwt/jwa/eddsa を無条件 require |
Workers に libsodium なし |
vendor/jwt/configuration/jwk_configuration.rb |
kid_generator_type= をスタブ化 |
起動時に OpenSSL::Digest を引く副作用を避ける |
npm run test:jwt— 43 ケース: HS/RS/PS/ES/EdDSA × (encode-decode + tamper 拒否)、alg-none 拒否、alg 不一致拒否、exp/nbf/iss クレーム、decode(verify: false)、 2 セグメント検出、algorithms: [...]配列指定 など。npm test— 全スイート: 27 smoke + 14 http + 85 crypto + 43 jwt = 169 tests。GET /test/crypto(Workers self-test) — Phase 7 の 17 ケース + Phase 8 の 9 JWT ケース = 26 ケースを実稼働 Workers 上で回す。
- JWKS (
kty/kidで公開鍵セットを取得する仕組み) — OpenSSL JWK シリアライザが必要。JWT::JWK.create_fromは明確にエラーを返す。 - X5C (
x5cヘッダによる証明書チェーン検証) — OpenSSL::X509 非実装。 - ES256K (secp256k1) — Web Crypto 仕様外。
- カスタム署名アルゴリズム — SigningAlgorithm module は有効だが、 Opal async 対応の完了はユーザー側の責務。
Cloudflare Workers が [triggers] crons の時刻に発火する scheduled(event, env, ctx) ハンドラを Sinatra DSL で書けるようにする。Sidekiq-Cron や
whenever のようにアプリと同じファイルに schedule ブロックを並べるだけで、
Workers ランタイムからのクロン発火が D1 / KV / R2 に届く。
class App < Sinatra::Base
register Sinatra::Scheduled
# 5分ごとに D1 に行を入れる
schedule '*/5 * * * *', name: 'heartbeat' do |event|
db.execute_insert(
'INSERT INTO heartbeats (cron, scheduled_at, fired_at, note) VALUES (?, ?, ?, ?)',
[event.cron, event.scheduled_time.to_i, Time.now.to_i, 'phase9-heartbeat']
).__await__
end
# 1時間ごとに KV カウンタを更新(read-modify-write)
schedule '0 */1 * * *', name: 'hourly-housekeeping' do |event|
raw = kv.get('cron:hourly-counter').__await__
prev = raw ? JSON.parse(raw)['count'].to_i : 0
kv.put('cron:hourly-counter', { 'count' => prev + 1, 'last_run_at' => Time.now.to_i }.to_json).__await__
end
endwrangler.toml:
[triggers]
crons = [
"*/5 * * * *", # heartbeat — 5分ごと D1 書き込み
"0 */1 * * *", # hourly housekeeping — 1時間ごと KV カウンタ
]Cloudflare Workers の標準的な方法と全く同じ。wrangler dev --test-scheduled
を立てて、/__scheduled エンドポイントに cron をクエリパラメータで投げる。
$ npm run dev # 内部で wrangler dev を起動
# 別ターミナルから — 5分ごとのクロンを今すぐ発火
$ curl 'http://127.0.0.1:8787/__scheduled?cron=*/5+*+*+*+*'
Ran scheduled event
# D1 に行が入った
$ wrangler d1 execute homura-db --local \
--command "SELECT * FROM heartbeats ORDER BY id DESC LIMIT 1;"
{"cron":"*/5 * * * *","note":"phase9-heartbeat", ...}Phase 7 / 8 の /test/crypto と同じノリで、開発時に登録済みクロンの一覧と
任意発火を curl で確認できる。default deny — wrangler.toml [vars] HOMURA_ENABLE_SCHEDULED_DEMOS = "1"(あるいは .dev.vars で上書き)
にしないと 404 を返す。
$ curl http://127.0.0.1:8787/test/scheduled
{"jobs":[
{"name":"heartbeat","cron":"*/5 * * * *", ...},
{"name":"hourly-housekeeping","cron":"0 */1 * * *", ...}
]}
# 手動で hourly cron だけ発火
$ curl -X POST 'http://127.0.0.1:8787/test/scheduled/run?cron=0%20*/1%20*%20*%20*'
{"fired":1,"total":2,
"results":[{"name":"hourly-housekeeping","cron":"0 */1 * * *","ok":true,"duration":0.003}],
"cron":"0 */1 * * *","registered_crons":["*/5 * * * *","0 */1 * * *"]}| 引数 | 型 | 必須 | 説明 |
|---|---|---|---|
cron |
String | ✓ | 5- または 6-フィールドのクロン式。wrangler.toml の [triggers] crons の文字列と完全一致しないとマッチしない |
name: |
String | — | ログ用ラベル(既定: クロン式そのもの) |
match: |
Proc | — | 完全一致以外のマッチング(テスト用、->(c) { true } で常時発火) |
| ブロック | |event| |
✓ | Cloudflare::ScheduledEvent (#cron / #scheduled_time / #type) を受け取る |
schedule ブロックの中では HTTP ルートと同じヘルパが使える:
| ヘルパ | 値 |
|---|---|
db |
Cloudflare::D1Database ラッパ(D1 バインディング設定時のみ) |
kv |
Cloudflare::KVNamespace ラッパ |
bucket |
Cloudflare::R2Bucket ラッパ |
env |
'cloudflare.cron' / 'cloudflare.scheduled_time' / 'cloudflare.env' / 'cloudflare.ctx' を含む Hash |
wait_until(promise) |
ctx.waitUntil(promise) 相当。長時間 Promise をハンドラ完了後も走らせる |
logger |
info / warn / error / debug を持つ簡易ロガー |
D1 / KV / R2 / fetch / 暗号系と同じく、内部で __await__ を呼ぶブロックは
Opal # await: true モードで動く。app/hello.rb の先頭にこのマジックコメントが
ある限り、schedule do ... end の中で kv.get(key).__await__ のような同期風
構文が使える(ES8 await に変換される)。
ブロックが投げた例外は per-job rescue に捕まり、結果の results 配列に
ok: false, error: "Class: msg" として記録される。一つのクロンが落ちても兄弟ジョブは
止まらない。
- 動的クロン登録 —
wrangler.tomlの静的宣言のみ。実行時にunschedule/rescheduleする API は提供しない(プラットフォーム制約)。 - 長時間ジョブ — Workers の CPU 時間制限あり(30s wall、〜30s CPU)。
外部 fetch を伴う重い処理は
wait_untilで続行させる。 - クロス-job sequencing — 各ジョブは並列・独立。
A の完了を待って Bのような宣言的シーケンスはなし(必要なら呼び出し順をschedule宣言で 制御)。
29 ケースの回帰スイート (test/scheduled_smoke.rb) — DSL 登録、クロン式
バリデーション、ディスパッチ、ScheduledContext ヘルパ、ScheduledEvent.from_js、
カスタム match proc、per-job エラー隔離、globalThis.__HOMURA_SCHEDULED_DISPATCH__
JS フック経由の round-trip。
$ npm run test:scheduled
29 tests, 29 passed, 0 failedPhase 11B adds three Cloudflare-native Worker bindings on top of the Phase 3 D1/KV/R2 foundation — always the same "the wrapper IS the API" shape that turns a JS binding into a plain Ruby object:
| Binding | Ruby API | Demo route | Self-test |
|---|---|---|---|
| Durable Objects | Cloudflare::DurableObjectNamespace / Stub / Storage + Cloudflare::DurableObject.define handler DSL |
GET /demo/do?name=...&action=inc|peek|reset |
GET /test/bindings |
| Cache API | Cloudflare::Cache.default / .match / .put / .delete + Sinatra cache_get(key, ttl:) { block } helper |
GET /demo/cache/heavy?v=... |
GET /test/bindings |
| Queues | Cloudflare::Queue#send / #send_batch + consume_queue 'q' do |batch| ... end DSL |
POST /api/enqueue + GET /demo/queue/status |
GET /test/bindings |
Default deny like every other homura demo — all four routes above are
gated behind HOMURA_ENABLE_BINDING_DEMOS=1 (wrangler [vars] entry).
One generic JS class HomuraCounterDO is exported from
src/worker.mjs; it forwards every fetch(req) call to a Ruby handler
registered with Cloudflare::DurableObject.define. Storage is serialised
to JSON on write, parsed back on read — Ruby code never touches JS.
# app/hello.rb (excerpt) — the whole DO class lives in Ruby.
Cloudflare::DurableObject.define('HomuraCounterDO') do |state, request|
prev = (state.storage.get('count').__await__ || 0).to_i
if request.path.end_with?('/inc')
state.storage.put('count', prev + 1).__await__
[200, { 'content-type' => 'application/json' },
{ 'count' => prev + 1, 'do_id' => state.id }.to_json]
elsif request.path.end_with?('/reset')
state.storage.delete('count').__await__
[200, {}, '{"reset":true}']
else
[200, {}, { 'count' => prev }.to_json]
end
end
get '/demo/do' do
stub = env['cloudflare.DO_COUNTER'].get_by_name(params['name'] || 'global')
res = stub.fetch("https://homura-do.internal/#{params['action']}", method: 'POST').__await__
res.body
endwrangler.toml:
[[durable_objects.bindings]]
name = "COUNTER"
class_name = "HomuraCounterDO"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["HomuraCounterDO"]Live evidence (wrangler dev --local + HOMURA_ENABLE_BINDING_DEMOS=1):
$ curl 'http://127.0.0.1:8787/demo/do?name=evidence&action=inc' # ×4
{"count":1,"do_id":"2ce054..."}
{"count":2,"do_id":"2ce054..."}
{"count":3,"do_id":"2ce054..."}
{"count":4,"do_id":"2ce054..."}
$ curl 'http://127.0.0.1:8787/demo/do?name=evidence&action=peek'
{"count":4,"do_id":"2ce054..."}
# app/hello.rb
get '/demo/cache/heavy' do
content_type 'application/json'
cache_get(request.url, ttl: 60) do
# expensive PBKDF2 (50_000 iterations) — only runs on MISS
derived = OpenSSL::KDF.pbkdf2_hmac('homura-phase11b',
salt: SecureRandom.random_bytes(16),
iterations: 50_000, length: 32, hash: 'SHA256')
{ 'derived_hex' => derived.unpack1('H*'), 'computed_at' => Time.now.to_i }.to_json
end.__await__ # cache_get is async — route must await
end# first hit — MISS, 6ms
{"derived_hex":"6ac25e...","cache":"MISS","elapsed_ms":6}
# second hit on the same URL — HIT, 1ms (same derived_hex proves it's cached)
{"derived_hex":"6ac25e...","cache":"HIT","elapsed_ms":1}
The Workers Cache API requires the stored Response to have
Cache-Control: max-age>0 and a Date header — the cache_get
helper sets both automatically so callers can't forget.
# app/hello.rb — producer side
post '/api/enqueue' do
content_type 'application/json'
jobs_queue.send(JSON.parse(request.body.read)).__await__
status 202
{ 'enqueued' => true }.to_json
end
# consumer side — DSL runs inside `src/worker.mjs#queue(batch, env, ctx)`
consume_queue 'homura-jobs' do |batch|
msgs = batch.messages
i = 0
while i < msgs.length # indexed while — see `# await: true` notes
msg = msgs[i]
kv.put("queue:last-consumed:#{i}",
{ 'id' => msg.id, 'body' => msg.body, 'consumed_at' => Time.now.to_i }.to_json,
expiration_ttl: 86_400).__await__
msg.ack
i += 1
end
batch.size
endwrangler.toml:
[[queues.producers]]
binding = "JOBS_QUEUE"
queue = "homura-jobs"
[[queues.consumers]]
queue = "homura-jobs"
max_batch_size = 3
max_batch_timeout = 2
max_retries = 3Live evidence (miniflare 3 local emulator):
$ for t in alpha beta gamma; do
curl -s -X POST -H 'content-type: application/json' \
-d "{\"task\":\"$t\"}" http://127.0.0.1:8787/api/enqueue
done
$ sleep 3 # wait for max_batch_timeout
$ curl http://127.0.0.1:8787/demo/queue/status
{"queue":"homura-jobs","count":3,"recent":[
{"id":"cabc5a...","body":{"task":"gamma"},"batch_index":0},
{"id":"819052...","body":{"task":"beta"},"batch_index":1},
{"id":"...","body":{"task":"alpha"},"batch_index":2}
]}
A fallback POST /test/queue/fire route manually invokes
Cloudflare::QueueConsumer.dispatch_js with a synthesised batch — useful
when miniflare's auto-dispatch loop is flaky on rapid wrangler dev
restarts.
Mirrors the Phase 7 /test/crypto pattern: one HTTP endpoint that
exercises every binding wrapper and reports pass/fail per case.
$ curl http://127.0.0.1:8787/test/bindings
{"passed":3,"failed":0,"total":3,"cases":[
{"case":"DurableObject counter inc/peek/reset round-trip","pass":true, ...},
{"case":"Cache API match after put returns same body","pass":true, ...},
{"case":"Queue producer send() returns without error","pass":true, ...}
]}
Opal treats a multi-line backtick x-string as a statement (not an expression), which silently drops the returned value. Every wrapper in Phase 11B uses the single-line IIFE pattern for that reason:
# Works — single-line expression, value is the Promise.
js_promise = `(async function(js, req) { await js.put(req, ...); })(#{js}, #{req})`
js_promise.__await__Multi-line backticks work fine when assigned (the other Phase 3/6/7/8/9
wrappers use them that way), but at end-of-method they can sneakily
return undefined. The put / match / delete / send / fetch
helpers in lib/cloudflare_workers/{cache,queue,durable_object}.rb
document this at the call site.
初回 PR 後、本家 PR 無し / 費用発生無し の範囲で 「工数で潰せる妥協」を全部潰した アップデート:
- DurableObject WebSocket (Hibernation API) —
Cloudflare::DurableObject.define_web_socket_handlersでon_message/on_close/on_errorを Ruby で登録。state.accept_web_socket/state.web_socketsラッパも同時提供。/demo/do/wsが 101 upgrade + フレーム echo + 同一 DO counter の increment を 行う実機デモ (Nodewsclient で 3 frames round-trip 確認)。Sinatra ルートが WebSocket 101 を 返せるようCloudflare::RawResponseラッパとbuild_js_responseのパススルー分岐を追加。 - Named Cache demo —
/demo/cache/named?namespace=X&key=Yでcaches.open(X)間の key 衝突が ないことを実機確認。smoke test にも 2 namespace 独立ケース追加。 - Cache TTL 期限切れ — 時間制御可能な fake で
max-age越えのcache.matchが nil に落ちる (post-expiry MISS)ケースを追加。 - DLQ 実機検証 —
[[queues.consumers]] dead_letter_queue = "homura-jobs-dlq"+ DLQ 側 consumerPOST /demo/queue/force-dlq({ fail: true }を送ると main が retry → max_retries 超で DLQ 行き)。 miniflare local で/demo/queue/dlq-status経由の round-trip 実機確認。
- Queue send_batch 大量ケース — 100 件 batch の順序保存 + 件数検証。
- DO
blockConcurrencyWhile— 共有カウンタへの並行 read-modify-write がシリアライズされる ケースを fake mutex で再現。 - #9 Opal multi-line backtick audit —
http.rb/ai.rbに「変数代入ありの multi-line は 安全、末尾式として置くと Promise silent drop」警告コメント追加。
これで smoke 合計 280 ケース (DO 31 / Cache 18 / Queue 22 + 既存 209)。
初回 56 + max-effort 15 = 71 ケースの新規回帰 (test/do_smoke.rb 31 / test/cache_smoke.rb 18 /
test/queue_smoke.rb 22) + Workers self-test /test/bindings。
$ npm run test:do && npm run test:cache && npm run test:queue
31 tests, 31 passed, 0 failed
18 tests, 18 passed, 0 failed
22 tests, 22 passed, 0 failedPhase 12 は 「Ruby 生まれの Dataset DSL で D1 を喋る」 パック。
Sequel.connect(adapter: :d1, d1: env['cloudflare.DB']) で接続できて、
db[:users].where(active: true).order(:name).limit(10).all.__await__ が
Cloudflare Workers 上で素直に動く。ハンドル済みの実機証跡は
/test/sequel(8/8 緑)と /demo/sequel で確認できる。
require 'sequel'
class App < Sinatra::Base
get '/demo/sequel' do
seq_db = Sequel.connect(adapter: :d1, d1: env['cloudflare.DB'])
# そのまま Sequel の DSL:
rows = seq_db[:users].order(:id).limit(10).all.__await__
json rows
end
endWorkers ランタイム上で Sequel の Dataset DSL が返す Promise を
.__await__ で unwrap する。Dataset#each / #all / #first / #count /
#insert / #update / #delete / #transaction すべて async な D1 コールに
透過的に繋がる(vendor/sequel/dataset/actions.rb に
# await: true + 各 action に .__await__ 差し込み済み)。
migration 本体は build-time 一択。CRuby 側で Sequel.migration do; change do; create_table(:posts) ...; end; end を評価して SQL 文字列に
書き出し、wrangler d1 migrations apply が適用する。Opal バンドルには
migration ランタイム(File.directory? / Dir.new / load / Mutex
を踏む箇所)は入らない。
# db/migrations/0001_create_posts.rb を書く
bin/homura-migrate compile db/migrations --out db/migrations
wrangler d1 migrations apply homura-db --local生成される SQL は SQLite 方言のため D1 が素で喰う:
CREATE TABLE `posts` (
`id` integer NOT NULL PRIMARY KEY AUTOINCREMENT,
`title` varchar(255) NOT NULL,
`body` varchar(255),
`created_at` timestamp DEFAULT (datetime(CURRENT_TIMESTAMP, 'localtime'))
);ROADMAP の「vendor + 最小 Opal patch」方針に従い、Sequel v5.103.0 を
vendor/sequel/ 配下に丸ごと固定して homura 専用の patch を
あてている。各 patch サイトは # homura patch (Phase 12): コメント付き:
class_eval(String)/module_eval(String)→define_method— Workers のCode generation disallowedに抵触する 11 箇所 (sql.rb/dataset/query.rb/dataset/sql.rb/timezones.rb)def_sql_methodの class_eval 生成をdefine_method+ lambda 分岐に書き換え — シーケンス型 + 分岐型(SQLite version 依存)両対応の parser (sqlite / postgres / mssql / opts[:values] 各条件パターン網羅)to_s_methodの args 式 parser —'@op, @args'/'@table, @column'等を ivar 名カンマ区切りと解釈、instance_variable_getで解決HomuraSqlBuffer可変 SQL バッファ — Opal の String は immutable (<<が NotImplementedError) なので、Array-backed の shim をsql_string_originとliteral_appendの Symbol キャッシュで利用- async Promise 貫通 —
vendor/sequel/dataset/actions.rbに# await: true+Dataset#each/#_all/#with_sql_firstで.__await__差し込み。break/return/nextが async 境界を 越えて LocalJumpError になる箇所は capture-then-drop 形式に書換 Module#class_evalのrequire_relativehack — Opal がrequire_relative "foo"をself.$require("/abs/path/foo")に 書き換える挙動を吸収する path normalizer (__homura_normalize_path)[]alias —class << self; alias_method :[], :expr; endが Opal の singleton extend 非対応で失敗するためdef self.[]で explicit forward- Symbol は JS String と同一 —
Database#[](symbol)がis_a?(String)真 判定でfetchパスに流れる問題をis_a?(Symbol)優先で救う Mutex/Thread.current/BigDecimalshim —lib/sequel_opal_patches.rbで no-op 相当を提供- connection pool —
SingleConnectionPoolとShardedSingleConnectionPoolを eager require、ThreadedConnectionPool系はPOOL_CLASS_MAPから明示的 error に
/test/sequel に 8 ケース(adapter wiring 3 + SQL 生成 3 + D1 実機
round-trip 2)を配置。wrangler dev + curl http://127.0.0.1:8787/test/sequel | jq
で即検証できる。
test/sequel_smoke.rb— Node.js 側 22 ケース(offline SQL 生成 + adapter wiring + mock D1 round-trip + JOIN/GROUP BY/subquery + transactions + 識別子/schema primitives)/test/sequelWorkers self-test — 実機 D1 round-trip 8 ケースbin/homura-migrateCLI — migration → SQL 書き出し単体でdb/migrations/0001_create_posts.rb→0001_create_posts.sql動作確認済
Sequel::Model— AR 風の magic finder は移植しない。Phase 12 は Dataset DSL に集中(ROADMAP不採用から AR 項目は Phase 12 以降 Sequel Dataset を代替として案内する)。- Threaded / TimedQueue pool — Workers isolate は単一スレッド、 Mutex/ConditionVariable/Thread.new を踏む pool は使えない
- PostgreSQL / MySQL adapter — D1 (SQLite) 専用、他 adapter は非同梱
- schema.rb 自動生成 — migration が source of truth(Sequel 既定挙動)
Phase 11A は「HTTP 周り 3 点パック」。既存の Phase 6 (Net::HTTP shim) / Phase 10.3 (AI streaming) をベースに、downstream Ruby gem との互換性を 底上げする基礎固め。
本物の ruby-faraday gem (〜9 kLOC + アダプタ/middleware) を vendor する代わりに、
Cloudflare Workers が持つ唯一のトランスポート (globalThis.fetch →
Cloudflare::HTTP.fetch) の上に Faraday の公開 API の 95% を直書き。
require 'faraday'
client = Faraday.new(url: 'https://api.github.com') do |c|
c.request :json # Hash body → JSON string
c.response :json # レスポンス body を JSON.parse
c.response :raise_error # 4xx/5xx で Faraday::ResourceNotFound など
c.request :authorization, :bearer, ENV['GH_TOKEN']
end
res = client.get('/users/kazuph').__await__
res.status # => 200
res.body # => { "login" => "kazuph", ... }
res.success? # => true
client.post('/widgets') do |req|
req.headers['X-Custom'] = 'yay'
req.body = { 'name' => 'homura' }
end.__await__- Top-level shortcut:
Faraday.get / post / put / patch / delete / head - Connection builder:
Faraday.new(url:, headers:, params:) { |c| ... } - Middleware:
:json(encode/decode),:url_encoded,:raise_error,:authorization, :basic | :bearer | :token, ...,:logger - Error hierarchy:
Faraday::ClientError/ServerError/ResourceNotFound(404) /UnauthorizedError(401) /ForbiddenError(403) /ConflictError(409) /UnprocessableEntityError(422) /TooManyRequestsError(429) /TimeoutError/ConnectionFailed Faraday::Utils.build_queryで nested Hash のa%5Bb%5D=1&list%5B%5D=1形式エンコードも。
この shim のおかげで Faraday 依存の主要 gem (octokit 系の薄い client、 slack-ruby-client、OpenAI 互換 client など) が そのまま Workers で動く ことが期待できる。
$ npm run test:faraday
13 tests, 13 passed, 0 failedWorkers には書き込める FS が無いので Rack の既定 Tempfile 路線が使えない。
代わりに src/worker.mjs 側で multipart リクエストだけ request.arrayBuffer()
→ latin1 バイト文字列に変換して Ruby へ渡し、lib/cloudflare_workers/multipart.rb
のバイナリ安全パーサが Cloudflare::UploadedFile を生成する。
post '/api/upload' do
content_type 'application/json'
file = params['file'] # => Cloudflare::UploadedFile
note = params['note'] # => 普通の String
# 注: このルートは `.__await__` を含む (= async) ので Sinatra の
# `halt` / `throw :halt` は使わない。homura の確立したパターンで
# `status N; next(body)` を使う(Phase 8/10 の JwtAuth helper 書き換え
# コメント参照)。throw は async 境界を越えて Sinatra の
# `catch :halt` から抜けてしまう。
unless file.is_a?(Cloudflare::UploadedFile)
status 400
next({ error: 'missing "file"' }.to_json)
end
# latin1 バイト文字列 → real Uint8Array。これを R2 / fetch へ流すと
# バイトが UTF-8 に触れず無傷で届く。
bucket.put("uploads/#{file.filename}", file.to_uint8_array, file.content_type).__await__
{ stored: true, filename: file.filename, size: file.size, note: note }.to_json
endUploadedFile#filename / #content_type / #size / #read / #bytes_binstrUploadedFile#to_uint8_array→new Uint8Array(...)で真バイト配列UploadedFile#to_blob→new Blob([u8], { type: ct })UploadedFile[:filename]/[:type]/[:tempfile](rack-compat Hash shape)- RFC 5987
filename*=UTF-8''...のパーセントエンコードも decode - boundary の quoted/bare form 両対応 (
boundary="foo bar"もOK)
$ printf 'binary-payload' > /tmp/x.bin
$ curl -F "file=@/tmp/x.bin;type=application/octet-stream" -F "note=hi" \
http://127.0.0.1:8787/api/upload
{"stored":true,"key":"phase11a/uploads/abc-x.bin","filename":"x.bin",
"content_type":"application/octet-stream","size":14,"note":"hi"}
$ npm run test:multipart
10 tests, 10 passed, 0 failedWorkers の new Response(ReadableStream) を Ruby 側の DSL で書けるよう、
Cloudflare::SSEStream + Cloudflare::SSEOut を Sinatra::Streaming 拡張
として同梱。Ruby のブロックから書き込んだチャンクが、JS の TransformStream
→ new Response(readable, ...) を経由してクライアントに届く。
register Sinatra::Streaming
get '/demo/sse' do
sse do |out|
i = 0
while i < 5
out.event(
{ tick: i, ts: Time.now.to_i }.to_json,
event: 'heartbeat',
id: i.to_s
)
out.sleep(1).__await__ # setTimeout 経由の真の 1 秒 await
i += 1
end
out.event('done', event: 'close')
end
end$ curl -sN http://127.0.0.1:8787/demo/sse # 5 秒かけて流れる
event: heartbeat
id: 0
data: {"tick":0,"ts":1776461676}
event: heartbeat
id: 1
...
event: close
data: done- 書き込みは fire-and-forget (
out << chunk/out.event(...)) で WritableStream の内部キューに積まれる。close時にPromise.all(pending)→writer.close()をまとめて await するので、送信順が守られつつ route の async ブロックはブロックされない。 - 1 秒 sleep 等で本当に非同期 suspend したい場合は
out.sleep(1).__await__。 ブロック自体を# await: true文脈で動かすにはwhileループ推奨 (5.timesは同期反復なので await が詰まる)。 - 例外時は
ensureで必ず writer を閉じる。クライアントはdone: trueを見る。
$ npm run test:streaming
11 tests, 11 passed, 0 failed$ curl http://127.0.0.1:8787/test/foundations # 要 HOMURA_ENABLE_FOUNDATIONS_DEMOS=1
{"passed":6,"failed":0,"total":6,"cases":[
{"pass":true,"case":"Faraday GET with :json middleware round-trips"},
{"pass":true,"case":"Faraday raise_error raises ResourceNotFound on 404"},
{"pass":true,"case":"Faraday :json middleware encodes Hash body (offline)"},
{"pass":true,"case":"Multipart parser extracts file + text field"},
{"pass":true,"case":"UploadedFile#to_uint8_array preserves raw bytes"},
{"pass":true,"case":"SSEStream frames data correctly"}
]}The project follows a strict four-phase plan (see
.artifacts/homura/PLAN.md, not tracked in git). The current state
of each phase:
| Phase | Goal | Status |
|---|---|---|
| Phase 0 | New repo + Opal → Workers boot with a plain puts. |
✅ shipped at commit a09b399 |
| Phase 1 R | Pure Ruby Rack lambda { |env| [200, …] } returning real HTTP responses through a standard Rack handler. |
✅ shipped at commit 0dd4005 |
| Phase 2 | Real janbiedermann/sinatra compiled and served through the Rack handler, full middleware chain (Rack::Protection headers), production curl returning actual Sinatra bodies, no ERB-in-dev-mode workarounds, no request.body.read stub. |
✅ shipped at commits d74c329 / e6d5f66 / 93fba66 — and the 5 1st-pass compromises (body stub, APP_ENV force, force_encoding no-op, Sinatra-side next patch, grep-ability) were all closed in a subsequent pass. |
| Phase 3 | D1 / KV / R2 bindings callable from real Sinatra routes on Workers. | ✅ shipped at commits ba0a772 / 4210de5. All nine CRUD routes verified on production. |
| Phase 4 | Evidence collection + マスター + Codex double review. | In progress. |
| Phase 6 | HTTP client foundation — Cloudflare::HTTP.fetch wrapping globalThis.fetch, plus a Net::HTTP shim (get / get_response / post_form) and Kernel#URI so unmodified Ruby HTTP code can reach the network through the Workers fetch API. |
✅ shipped on feature/phase6-fetch. 14 new smoke tests pass; demos at /demo/http and /demo/http/raw hit the public ipify API. |
| Phase 7 | Crypto primitives — full RS/PS/ES JWT alg coverage, RSA-OAEP, AES-GCM/CBC/CTR, ECDH (P-256/384/521 + X25519), Ed25519/EdDSA, OpenSSL::BN, KDF (PBKDF2 + HKDF), SecureRandom, PEM I/O. node:crypto sync + Web Crypto subtle async hybrid; CTR streaming via per-block subtle calls; binary plaintext byte-transparent; verify raises on key/algo errors and only returns false on signature mismatch. | ✅ shipped on feature/phase7-crypto. 85 crypto smoke + Workers self-test endpoint /test/crypto (17 cases) + bin/test-on-workers shell script for in-Worker regression. |
| Phase 8 | JWT 認証フレームワーク — vendored ruby-jwt v2.9.3 に Opal/async パッチを適用。HS/RS/PS/ES/EdDSA 全アルゴリズム対応、Sinatra::JwtAuth ヘルパで authenticate! / current_user / issue_token、KV-backed refresh token、/api/login?alg=<name> で全アルゴリズムが実働 Workers 上で発行・検証できる。JWT.encode / JWT.decode は subtle バックエンドのため async(caller が .__await__)、HS256 系は sync。 |
✅ shipped on feature/phase8-jwt. 43 jwt smoke + Workers self-test /test/crypto が 26 ケース(JWT 9 追加)+ dogfooding で全 7 alg のログイン→/api/me→refresh を実測。 |
| Phase 9 | Scheduled Workers (Cron Triggers) — src/worker.mjs#scheduled を経由して globalThis.__HOMURA_SCHEDULED_DISPATCH__ から Sinatra ディスパッチャに委譲。Sinatra::Scheduled 拡張で schedule '*/5 * * * *' do |event| ... end DSL(db / kv / bucket / wait_until ヘルパ込み)。ブロックは define_method 経由でコンパイルされるため # await: true の __await__ がそのまま使え、D1 / KV へのリード・モディファイ・ライトが Workers ランタイムから正しく到達する。per-job 例外隔離 + /test/scheduled /test/scheduled/run 内省 API(HOMURA_ENABLE_SCHEDULED_DEMOS で default deny)。 |
✅ shipped on feature/phase9-cron. 29 scheduled smoke + 実機 wrangler dev --test-scheduled で /__scheduled?cron=... 経由の D1 行追加と KV カウンタ increment を実測(4 連発で count: 1→4)。 |
| Phase 10 | Workers AI binding + Sinatra /chat UI + /api/chat/* JWT-gated endpoints(Gemma 4 + gpt-oss-120b、KV-backed 会話履歴、SSE streaming サポート)。 |
✅ shipped on feature/phase10-ai. |
| Phase 11A | HTTP foundations 基礎固めパック — ① Faraday 互換 shim (vendor/faraday.rb) で Faraday.new { |c| c.request :json; c.response :json, :raise_error } 一式。② multipart/form-data バイナリ受信 (src/worker.mjs + lib/cloudflare_workers/multipart.rb) + Cloudflare::UploadedFile(latin1 byte-str ↔ real Uint8Array)。③ Sinatra streaming / SSE (Cloudflare::SSEStream + Sinatra::Streaming) で sse do |out| ... end が Workers ReadableStream に直通し、/demo/sse で 5 秒かけて 5 tick 流れる。Workers self-test /test/foundations 6 ケース (HOMURA_ENABLE_FOUNDATIONS_DEMOS=1)。 |
✅ shipped on feature/phase11a-http-foundations. 34 smoke (13 faraday + 10 multipart + 11 streaming) + /test/foundations 6/6 実機グリーン。 |
| Phase 11B | Cloudflare native bindings — Durable Objects (Cloudflare::DurableObject.define handler DSL + DurableObjectNamespace / Stub / Storage ラッパ)、Cache API (Cloudflare::Cache + cache_get helper、HIT/MISS 自動判定)、Queues (Cloudflare::Queue#send / #send_batch プロデューサ + consume_queue 'q' do |batch| ... end DSL + queue(batch, env, ctx) 配送)。/test/bindings セルフテストと 56 ケースの smoke suite。HOMURA_ENABLE_BINDING_DEMOS で default deny。 |
✅ shipped on feature/phase11b-cf-bindings. DO カウンタ 1→2→3→4、Cache MISS(6ms)→HIT(1ms) 同一 derived_hex、Queue /api/enqueue → auto 消費 → KV 書き込み round-trip を実機で実測。 |
| Phase 12 | Sequel (vendored v5.103.0) + D1 adapter + migration CLI — Sinatra ルートで Sequel.connect(adapter: :d1, d1: env['cloudflare.DB']) → db[:users].where(...).order(...).limit(...).all.__await__ の完全な Dataset DSL が実機 D1 で動作。SQLite dialect 共有、SingleConnectionPool 強制、async Promise チェーン貫通(vendor/sequel/dataset/actions.rb に # await: true + 各 action に .__await__ 差し込み)、HomuraSqlBuffer による String immutability 回避。bin/homura-migrate compile で Ruby migration DSL を SQL に書き出し → wrangler d1 migrations apply で反映(Opal バンドル非同梱)。/demo/sequel / /demo/sequel/sql / /test/sequel (8/8) を実機で実測。Dataset#count / #first / #all / #insert / #update / #delete / #transaction / JOIN / GROUP BY / subquery が緑。 |
✅ shipped on feature/phase12-sequel. 22 sequel smoke + 既存 341 smoke 全緑で合計 363 tests、bundle +800KB uncompressed (+200KB gzipped、6.3MB/1.36MB)。 |
-
kazuph/homuraexists as a new GitHub repository. -
app/hello.rbwritten in real CRuby syntax withrequire 'sinatra/base'and Sinatra DSL, no JavaScript mixed in. - Compiled to ESM via Opal and bundled into a Cloudflare Workers Module Worker.
- Uses real
janbiedermann/sinatra(no DSL re-implementation, no compatibility layer fakes). - D1 / KV / R2 bindings callable from Sinatra routes via the adapter's Ruby wrappers.
-
wrangler devandwrangler deployboth serve the Sinatra app.
None of the homura patches are submitted upstream, and there
are no plans to do so. The vendored copies under vendor/**/* are a
loose fork for a specific deployment target, not a staging area for
PRs to opal/opal, janbiedermann/sinatra, rack/rack, or
sinatra/mustermann.
Most of the patches only make sense inside the Opal + Cloudflare
Workers corner case (ERB bypass, Promise-aware invoke,
calculate_content_length? Promise guard, Sinatra::Base.new!
explicit def, Forwardable dot-path walker, immutable-String
rewrites on Rack and Mustermann, JS-regex flag rewrites). Pushing
them upstream would regress or muddy the implementations for normal
Ruby users.
Even the three patches that look like generic Opal compiler bug
fixes (dstr regex anchor normalization, next <expr> in a while
loop, UncaughtThrowError parent class) are staying local — they
need upstream-style discussion and test coverage that homura
isn't going to own.
Every patch site is marked with a # homura patch: comment so the
diff against upstream is recoverable at any time via
rg "homura patch". If you maintain Opal / Sinatra / Rack /
Mustermann and want to borrow an idea, please lift the relevant
snippet rather than taking a PR from here — that way the upstream
variant can be written in the style and with the tests the upstream
project prefers.
This repo enforces a no-fallback rule: the means is the goal. Running the real Sinatra on real Opal on real Cloudflare Workers is the entire point. Any deviation that "just makes it work" — swapping Sinatra for a compatibility DSL, falling back to mruby or ruby.wasm, switching to Cloudflare Containers, stubbing a route without actually running Sinatra's code — immediately invalidates that phase's deliverables. Patches are allowed; spec reduction is not.
The plan document (.artifacts/homura/PLAN.md) lists the full
forbidden-fallback list and the review process (マスター + Codex
double review after every phase).
kazuph/hinoko— Hono-like Ruby DSL on mruby/WASI for Cloudflare Workers. Lightweight, custom DSL, proven to work.kazuph/homura— this repo, real Ruby + real Sinatra via Opal. Ambitious, fallback-forbidden, a lot more bytes on the wire in exchange for the actual gem ecosystem.
TBD. All vendored upstreams keep their original licenses — Opal is MIT, Sinatra is MIT, Rack is MIT.
