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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 772c92137bd489f019d78537753770d0ddc73f76825c48d4cab399532b5dee86
4
+ data.tar.gz: b9cfb81748aa74ede6731ff0aa76f5d148df52cc1e3ffc10a3d66c05167a2816
5
+ SHA512:
6
+ metadata.gz: 58b61c31af0bf8c46ce28e5a027b598539929a543fb429723bd9212defbfab8d5875b5733b14764d6ab964597354674de998e2ef84e4da933a5b1c0d8aec72e2
7
+ data.tar.gz: 36e72393465c2ee4411132447588f356f9e497062aa6a24aa442ebf515d43a96d5f1246593c912a105e509f6206dde397c264b8a5d5e1fc93f76148a76acdefb
data/CHANGELOG.md ADDED
@@ -0,0 +1,6 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-04-20)
4
+
5
+ - Initial extraction from homura as `homura-runtime` (Phase 15-B).
6
+ - Includes `cloudflare_workers`, `opal_patches`, and `runtime/{worker,setup-node-crypto}.mjs`.
data/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # homura-runtime
2
+
3
+ Core Ruby + Module Worker glue for [Opal](https://opalrb.com/) on [Cloudflare Workers](https://developers.cloudflare.com/workers/). This gem does **not** depend on Sinatra or Sequel; those live in sibling gems.
4
+
5
+ ## What you get
6
+
7
+ - `require 'opal_patches'` — additive patches for Opal corelib vs real-world gems (Rack, etc.).
8
+ - `require 'cloudflare_workers'` — Rack handler, Cloudflare bindings, multipart, queue, Durable Objects, etc.
9
+ - `runtime/worker_module.mjs` — fetch / scheduled / queue / DO adapters (**no Opal bundle import**).
10
+ - `runtime/worker.mjs` — thin bootstrap (crypto shim → bundle → `worker_module`) for legacy layouts.
11
+ - `runtime/setup-node-crypto.mjs` — `node:crypto` on `globalThis` before the Opal bundle loads.
12
+ - `bin/cloudflare-workers-build` — single build pipeline (ERB → assets → Opal → patch → `worker.entrypoint.mjs`). Use `--standalone` in generated apps.
13
+ - `docs/ARCHITECTURE.md` — wrangler `main`, codegen entrypoint, and fixed-import policy.
14
+
15
+ ## Quick start (homura monorepo)
16
+
17
+ 1. `Gemfile`: `gem 'homura-runtime', path: 'gems/homura-runtime'` and `gem 'opal-homura', '= 1.8.3.rc1', require: 'opal'` (path or exact pin).
18
+ 2. `bundle exec cloudflare-workers-build` — writes `build/hello.no-exit.mjs` and `build/worker.entrypoint.mjs`.
19
+ 3. `wrangler.toml`: `main = "build/worker.entrypoint.mjs"`, `compatibility_flags = ["nodejs_compat"]`.
20
+
21
+ ## Support matrix (indicative)
22
+
23
+ | Component | Verified (dev) |
24
+ |------------|------------------|
25
+ | Ruby | 3.4.x |
26
+ | Node | ≥ 20 |
27
+ | Wrangler | ^3.99 |
28
+ | Opal | = 1.8.3.rc1 |
29
+
30
+ ## Wrangler config
31
+
32
+ - **Supported as generated / documented**: `wrangler.toml` only.
33
+ - `wrangler.json` / `wrangler.jsonc`: manual conversion only; not generated by this toolchain.
34
+
35
+ ## License
36
+
37
+ MIT. See the repository `LICENSE`.
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Single happy path: ERB precompile → assets embed → Opal bundle → patch-opal-evals → worker.entrypoint.mjs
5
+ # No subcommands. Use --standalone for generated consumer apps (no homura inline routes).
6
+
7
+ require 'fileutils'
8
+ require 'open3'
9
+ require 'optparse'
10
+ require 'pathname'
11
+
12
+ module CloudflareWorkersBuild
13
+ class << self
14
+ def gem_root
15
+ spec = Gem.loaded_specs['cloudflare-workers-runtime']
16
+ return Pathname(spec.full_gem_path) if spec
17
+ Pathname(__FILE__).expand_path.join('../..')
18
+ end
19
+
20
+ def runtime_dir
21
+ gem_root.join('runtime')
22
+ end
23
+
24
+ def exe_path(name)
25
+ gem_root.join('exe', name)
26
+ end
27
+
28
+ def gem_lib(name)
29
+ s = Gem.loaded_specs[name] || raise("cloudflare-workers-build: gem #{name} not loaded; use bundle exec from app root")
30
+ File.join(s.full_gem_path, 'lib')
31
+ end
32
+ end
33
+ end
34
+
35
+ options = {
36
+ root: Dir.pwd,
37
+ standalone: false,
38
+ opal_input: ENV['HOMURA_OPAL_INPUT'] || 'app/hello.rb',
39
+ opal_output: ENV['HOMURA_OPAL_OUTPUT'] || 'build/hello.no-exit.mjs',
40
+ patch_input: ENV['HOMURA_OPAL_PATCH_INPUT'],
41
+ setup_import: nil,
42
+ bundle_import: nil,
43
+ worker_module_import: nil,
44
+ entrypoint_out: nil,
45
+ with_db: false
46
+ }
47
+
48
+ OptionParser.new do |o|
49
+ o.banner = 'usage: cloudflare-workers-build [options]'
50
+ o.on('--root PATH', 'Project root (default: cwd)') { |p| options[:root] = p }
51
+ o.on('--standalone', 'Consumer app (skip inline-routes; use Gemfile-resolved load paths)') do
52
+ options[:standalone] = true
53
+ end
54
+ o.on('--with-db', 'Include sequel-d1 on Opal load path (standalone)') do
55
+ options[:with_db] = true
56
+ end
57
+ o.on('--input PATH', 'Opal entry .rb') { |p| options[:opal_input] = p }
58
+ o.on('--output PATH', 'Opal bundle .mjs') { |p| options[:opal_output] = p }
59
+ o.on('--patch-input PATH', 'Override patch target (default: same as --output)') { |p| options[:patch_input] = p }
60
+ o.on('--setup-import PATH', 'setup-node-crypto import in entrypoint') { |p| options[:setup_import] = p }
61
+ o.on('--bundle-import PATH', 'Opal bundle import in entrypoint') { |p| options[:bundle_import] = p }
62
+ o.on('--worker-module-import PATH', 'worker_module.mjs import in entrypoint') { |p| options[:worker_module_import] = p }
63
+ o.on('--entrypoint-out PATH', 'Where to write worker.entrypoint.mjs') { |p| options[:entrypoint_out] = p }
64
+ end.parse!
65
+
66
+ root = Pathname(options[:root]).expand_path
67
+
68
+ if options[:standalone]
69
+ Dir.chdir(root) { require 'bundler/setup' }
70
+ options[:setup_import] ||= './cf-runtime/setup-node-crypto.mjs'
71
+ options[:bundle_import] ||= './build/hello.no-exit.mjs'
72
+ options[:worker_module_import] ||= './cf-runtime/worker_module.mjs'
73
+ options[:entrypoint_out] ||= 'worker.entrypoint.mjs'
74
+ else
75
+ options[:setup_import] ||= '../gems/homura-runtime/runtime/setup-node-crypto.mjs'
76
+ options[:bundle_import] ||= './hello.no-exit.mjs'
77
+ options[:worker_module_import] ||= '../gems/homura-runtime/runtime/worker_module.mjs'
78
+ options[:entrypoint_out] ||= 'build/worker.entrypoint.mjs'
79
+ end
80
+
81
+ patch_target = options[:patch_input] || options[:opal_output]
82
+
83
+ def run!(argv, chdir:)
84
+ Dir.chdir(chdir) do
85
+ system(*argv) || abort("cloudflare-workers-build: failed: #{argv.join(' ')}")
86
+ end
87
+ end
88
+
89
+ def run_opal_homura!(root, opal_input, opal_output)
90
+ argv = [
91
+ 'bundle', 'exec', 'opal',
92
+ '-c', '-E', '--esm', '--no-source-map',
93
+ '-I', 'build/auto_await/app', '-I', 'build/auto_await', '-I', 'app',
94
+ '-I', 'gems/homura-runtime/lib',
95
+ '-I', 'gems/sinatra-homura/lib',
96
+ '-I', 'gems/sequel-d1/lib',
97
+ '-I', 'lib', '-I', 'vendor', '-I', 'build',
98
+ '-r', 'opal_patches', '-r', 'cloudflare_workers',
99
+ '-r', 'homura_templates', '-r', 'homura_assets',
100
+ '-o', opal_output,
101
+ opal_input
102
+ ]
103
+ stderr_log = root.join('build/opal.stderr.log')
104
+ FileUtils.mkdir_p(root.join('build'))
105
+ env = { 'OPAL_PREFORK_DISABLE' => '1' }
106
+ out_err, status = Open3.capture2e(env, *argv, chdir: root.to_s)
107
+ File.write(stderr_log, out_err)
108
+ abort('cloudflare-workers-build: opal failed') unless status.success?
109
+ end
110
+
111
+ def homura_vendor_from_gemfile(project_root)
112
+ gf = project_root.join('Gemfile')
113
+ return unless gf.file?
114
+
115
+ txt = gf.read
116
+ return unless (m = txt.match(/cloudflare-workers-runtime['"]\s*,\s*path:\s*['"]([^'"]+)['"]/))
117
+
118
+ runtime_path = Pathname.new(m[1]).expand_path(project_root)
119
+ vend = runtime_path.join('..', '..', 'vendor').expand_path
120
+ vend if vend.directory?
121
+ end
122
+
123
+ def run_opal_standalone!(root, opal_input, opal_output, with_db:)
124
+ load_paths = []
125
+ hv = homura_vendor_from_gemfile(root)
126
+ load_paths << hv.to_s if hv
127
+ load_paths += [
128
+ 'build/auto_await/app', 'app',
129
+ CloudflareWorkersBuild.gem_lib('cloudflare-workers-runtime'),
130
+ CloudflareWorkersBuild.gem_lib('sinatra-cloudflare-workers')
131
+ ]
132
+ load_paths << CloudflareWorkersBuild.gem_lib('sequel-d1') if with_db
133
+ load_paths << 'vendor' if root.join('vendor').directory?
134
+ load_paths << 'build'
135
+
136
+ argv = ['bundle', 'exec', 'opal', '-c', '-E', '--esm', '--no-source-map']
137
+ load_paths.each { |p| argv.push('-I', p) }
138
+ argv += %w[-r opal_patches -r cloudflare_workers -r app_templates -r app_assets -o] + [opal_output, opal_input]
139
+
140
+ stderr_log = root.join('build/opal.stderr.log')
141
+ FileUtils.mkdir_p(root.join('build'))
142
+ env = { 'OPAL_PREFORK_DISABLE' => '1' }
143
+ out_err, status = Open3.capture2e(env, *argv, chdir: root.to_s)
144
+ File.write(stderr_log, out_err)
145
+ abort('cloudflare-workers-build: opal failed') unless status.success?
146
+ end
147
+
148
+ def write_entrypoint!(root, out_path, setup:, bundle:, worker_mod:)
149
+ body = <<~JS
150
+ // GENERATED by cloudflare-workers-build — do not edit by hand.
151
+ import "#{setup}";
152
+ import "#{bundle}";
153
+ export { default, HomuraCounterDO } from "#{worker_mod}";
154
+ JS
155
+ File.write(root.join(out_path), body)
156
+ end
157
+
158
+ unless options[:standalone]
159
+ run!(['ruby', 'bin/inline-routes-for-opal'], chdir: root)
160
+ run!(
161
+ [
162
+ 'ruby', CloudflareWorkersBuild.exe_path('auto-await').to_s,
163
+ '--input', 'app',
164
+ '--output', 'build/auto_await/app'
165
+ ],
166
+ chdir: root
167
+ )
168
+ run!(
169
+ [
170
+ 'ruby', CloudflareWorkersBuild.exe_path('auto-await').to_s,
171
+ '--input', 'build/routes_app_class_eval.rb',
172
+ '--output', 'build/auto_await/routes_app_class_eval.rb'
173
+ ],
174
+ chdir: root
175
+ )
176
+ run!(
177
+ [
178
+ 'ruby', CloudflareWorkersBuild.exe_path('compile-erb').to_s,
179
+ '--input', 'views',
180
+ '--output', 'build/homura_templates.rb',
181
+ '--namespace', 'HomuraTemplates'
182
+ ],
183
+ chdir: root
184
+ )
185
+ run!(
186
+ [
187
+ 'ruby', CloudflareWorkersBuild.exe_path('compile-assets').to_s,
188
+ '--input', 'public',
189
+ '--output', 'build/homura_assets.rb',
190
+ '--namespace', 'HomuraAssets'
191
+ ],
192
+ chdir: root
193
+ )
194
+
195
+ opal_in = Pathname(options[:opal_input])
196
+ opal_out = Pathname(options[:opal_output])
197
+ opal_in = root.join(opal_in) unless opal_in.absolute?
198
+ opal_out = root.join(opal_out) unless opal_out.absolute?
199
+ run_opal_homura!(root, opal_in.relative_path_from(root).to_s, opal_out.relative_path_from(root).to_s)
200
+ else
201
+ run!(
202
+ [
203
+ 'ruby', CloudflareWorkersBuild.exe_path('compile-erb').to_s,
204
+ '--input', 'views',
205
+ '--output', 'build/app_templates.rb',
206
+ '--namespace', 'AppTemplates'
207
+ ],
208
+ chdir: root
209
+ )
210
+ run!(
211
+ [
212
+ 'ruby', CloudflareWorkersBuild.exe_path('compile-assets').to_s,
213
+ '--input', 'public',
214
+ '--output', 'build/app_assets.rb',
215
+ '--namespace', 'AppAssets'
216
+ ],
217
+ chdir: root
218
+ )
219
+ run!(
220
+ [
221
+ 'ruby', CloudflareWorkersBuild.exe_path('auto-await').to_s,
222
+ '--input', 'app',
223
+ '--output', 'build/auto_await/app'
224
+ ],
225
+ chdir: root
226
+ )
227
+
228
+ opal_in = Pathname(options[:opal_input])
229
+ opal_out = Pathname(options[:opal_output])
230
+ opal_in = root.join(opal_in) unless opal_in.absolute?
231
+ opal_out = root.join(opal_out) unless opal_out.absolute?
232
+ run_opal_standalone!(root, opal_in.relative_path_from(root).to_s, opal_out.relative_path_from(root).to_s,
233
+ with_db: options[:with_db])
234
+ end
235
+
236
+ patch_rel = Pathname(patch_target)
237
+ patch_rel = root.join(patch_rel) unless patch_rel.absolute?
238
+ run!(
239
+ [
240
+ 'node',
241
+ CloudflareWorkersBuild.runtime_dir.join('patch-opal-evals.mjs').to_s,
242
+ patch_rel.relative_path_from(root).to_s
243
+ ],
244
+ chdir: root
245
+ )
246
+
247
+ write_entrypoint!(
248
+ root,
249
+ options[:entrypoint_out],
250
+ setup: options[:setup_import],
251
+ bundle: options[:bundle_import],
252
+ worker_mod: options[:worker_module_import]
253
+ )
254
+
255
+ $stderr.puts 'cloudflare-workers-build: ok'
@@ -0,0 +1,59 @@
1
+ # Cloudflare Workers + Opal — entrypoint topology (Phase 15-E)
2
+
3
+ ## 責務分離(確定)
4
+
5
+ | レイヤ | 役割 |
6
+ |--------|------|
7
+ | `wrangler.toml` の `main` | **Workers Module のエントリ**(`fetch` / `scheduled` / `queue` / DO クラスを export) |
8
+ | `worker.entrypoint.mjs`(生成物) | `setup-node-crypto` → **Opal bundle(副作用)** → `worker_module.mjs` の順で import。パスはプロジェクトごとに **固定文字列**(codegen) |
9
+ | Opal bundle(例: `build/hello.no-exit.mjs`) | **ビルド成果物**。Rack ディスパッチャ等を `globalThis` に登録 |
10
+ | `worker_module.mjs`(gem 同梱) | Rack / Cron / Queue / DO への **純粋な JS アダプタ**(Opal bundle を import しない) |
11
+
12
+ ## 禁止事項
13
+
14
+ - **ランタイム可変 path import**(例: `import(pathVar)` で Opal bundle を読む)は採用しない。Workers のバンドル解決と並行性の両方で破綻しやすい。
15
+
16
+ ## Before / After(概念)
17
+
18
+ ```mermaid
19
+ flowchart LR
20
+ subgraph before["Before (monorepo 固定)"]
21
+ W1[worker.mjs] -->|hard-coded| B1[../../../build/hello.mjs]
22
+ end
23
+ subgraph after["After (Phase 15-E)"]
24
+ E[worker.entrypoint.mjs] --> S[setup-node-crypto.mjs]
25
+ E --> O[build/*.mjs Opal bundle]
26
+ E --> M[worker_module.mjs]
27
+ end
28
+ ```
29
+
30
+ ## homura 本体
31
+
32
+ - `wrangler.toml` の `main` は `build/worker.entrypoint.mjs`。
33
+ - `bundle exec cloudflare-workers-build` が ERB / assets / Opal / patch / entrypoint 生成まで一括実行。
34
+
35
+ ## スキャフォールド済みアプリ
36
+
37
+ - プロジェクト直下に `worker.entrypoint.mjs`(`main` と一致)。
38
+ - `cf-runtime/` に `setup-node-crypto.mjs` と `worker_module.mjs` をコピー(gem から)。
39
+ - `bundle exec cloudflare-workers-build --standalone` が consumer 向けパイプラインを実行し、`Gemfile` の `path:` から homura の `vendor/` を追加ロードパスへ取り込み(digest / zlib 等の Workers 向け補助ファイル)。
40
+
41
+ ## Phase 17 — Email Service(`SEND_EMAIL`)
42
+
43
+ | 項目 | 内容 |
44
+ |------|------|
45
+ | Wrangler | `[[send_email]]` に `name = "SEND_EMAIL"`(Cloudflare Email Service · Agents Week 2026)。 |
46
+ | Rack env | `env['cloudflare.SEND_EMAIL']` は `Cloudflare::Email`(JS `env.SEND_EMAIL.send(...)` を Ruby 側で `await`)。 |
47
+ | 備考 | consumer アプリ側で verified sender を wrangler `[vars]` などに載せ、アプリから `from` に渡す。 |
48
+
49
+ ### Phase 17-E — `/cdn-cgi/*`(Rack に渡さない)とバインディング注入
50
+
51
+ | 項目 | 内容 |
52
+ |------|------|
53
+ | **`worker_module.mjs` の `fetch` 先頭** | `env.SEND_EMAIL` があれば **`globalThis.__OPAL_WORKERS__.sendEmailBinding`** にコピーしてから Rack を呼ぶ。Miniflare 等で `js_env.SEND_EMAIL` が欠ける場合でも Ruby が同じ JS オブジェクトを拾える。 |
54
+ | **`/cdn-cgi/*`** | **Sinatra に渡さない**。Miniflare の entry.worker は `/cdn-cgi/mf/scheduled` だけ先処理する。`/cdn-cgi/handler/email` などはユーザ Worker の `fetch` に届くため、ここで処理しないと Rack が 404 になる。将来 Phase 18(Email 受信)で `export async email(...)` と接続する前提。 |
55
+ | **D1 / KV / Queue との違い** | それらは典型的に `env['cloudflare.env']` 経由で JS バインディングを参照する。`SEND_EMAIL` は **Worker の `env` に付く生バインディング**を `worker_module` が先に global に載せ、`Cloudflare::Email` がそれを包む(Rack の `cloudflare.*` はラッパー用の入口)。 |
56
+
57
+ ## wrangler.json について
58
+
59
+ - **生成・サポート対象は `wrangler.toml` のみ**。`wrangler.json` / `wrangler.jsonc` は手動変換可だが、本ツールチェーンの前提外。
data/exe/auto-await ADDED
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Auto-Await transformer — build-time Ruby source rewriter.
5
+ # Scans app/**/*.rb, runs flow analysis, and inserts .__await__
6
+ # where the receiver is a known async-tainted source.
7
+ #
8
+ # Usage:
9
+ # ruby auto-await --input app --output build/auto_await/app
10
+ #
11
+ # The output directory mirrors the input. Files that need no change
12
+ # are skipped entirely (not copied), so the consumer can place the
13
+ # output directory earlier on the Opal load path and fall back to
14
+ # the original input for unchanged files.
15
+
16
+ require 'fileutils'
17
+ require 'pathname'
18
+
19
+ # cloudflare-workers-runtime lib path resolution
20
+ runtime_lib = ENV['CFW_RUNTIME_LIB']
21
+ unless runtime_lib
22
+ spec = Gem.loaded_specs['cloudflare-workers-runtime']
23
+ runtime_lib = spec ? File.join(spec.full_gem_path, 'lib') : File.expand_path('../lib', __dir__)
24
+ end
25
+ $LOAD_PATH.unshift(runtime_lib) unless $LOAD_PATH.include?(runtime_lib)
26
+
27
+ require 'cloudflare_workers/async_registry'
28
+ require 'cloudflare_workers/auto_await/analyzer'
29
+ require 'cloudflare_workers/auto_await/transformer'
30
+
31
+ options = { input: nil, output: nil, debug: ENV['CLOUDFLARE_WORKERS_AUTO_AWAIT_DEBUG'] == '1' }
32
+
33
+ ARGV.each_with_index do |arg, i|
34
+ case arg
35
+ when '--input' then options[:input] = ARGV[i + 1]
36
+ when '--output' then options[:output] = ARGV[i + 1]
37
+ when '--debug' then options[:debug] = true
38
+ end
39
+ end
40
+
41
+ abort('Usage: auto-await --input DIR --output DIR [--debug]') unless options[:input] && options[:output]
42
+
43
+ input_root = Pathname(options[:input]).expand_path
44
+ output_root = Pathname(options[:output]).expand_path
45
+
46
+ registry = CloudflareWorkers::AsyncRegistry.instance
47
+
48
+ # Auto-load async source registrations from all loaded gems.
49
+ CloudflareWorkers::AsyncRegistry.auto_load_gem_async_sources(debug: options[:debug])
50
+
51
+ # Load project-specific async source registrations if present.
52
+ project_async = File.join(Dir.pwd, 'lib', 'homura_async_sources.rb')
53
+ require project_async if File.exist?(project_async)
54
+
55
+ changed = 0
56
+ skipped = 0
57
+ errors = 0
58
+
59
+ paths = if File.directory?(input_root)
60
+ Dir.glob(input_root.join('**', '*.rb')).sort
61
+ else
62
+ [input_root.to_s]
63
+ end
64
+
65
+ paths.each do |path|
66
+ rel = Pathname(path).relative_path_from(input_root)
67
+ source = File.read(path)
68
+
69
+ # If the file already has a user-written # await: magic comment we
70
+ # still run the analyzer (so .__await__ is inserted) but we keep
71
+ # the original magic comment in place. The magic comment is only
72
+ # needed to tell Opal to wrap the file in an async function and to
73
+ # translate #__await__ calls into real JS await keywords.
74
+ has_magic = source.lines.first(5).any? { |l| l.match?(/#\s*await:/) }
75
+
76
+ begin
77
+ analyzer = CloudflareWorkers::AutoAwait::Analyzer.new(registry, debug: options[:debug])
78
+ buffer, nodes = analyzer.process(source, path)
79
+
80
+ if nodes.empty?
81
+ puts "[auto-await] skip (no changes) #{rel}" if options[:debug]
82
+ skipped += 1
83
+ next
84
+ end
85
+
86
+ transformed = CloudflareWorkers::AutoAwait::Transformer.transform(source, nodes, buffer)
87
+ unless has_magic
88
+ transformed = "# await: true\n" + transformed
89
+ end
90
+ out_path = output_root.join(rel)
91
+ FileUtils.mkdir_p(out_path.dirname)
92
+ File.write(out_path, transformed)
93
+ puts "[auto-await] write #{rel} (#{nodes.length} await#{'s' if nodes.length > 1})" if options[:debug]
94
+ changed += 1
95
+ rescue => e
96
+ $stderr.puts "[auto-await] ERROR #{rel}: #{e.message}"
97
+ errors += 1
98
+ end
99
+ end
100
+
101
+ puts "[auto-await] done: #{changed} changed, #{skipped} skipped, #{errors} errors"
102
+ exit(1) if errors > 0
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ #
4
+ # homura static-asset embedder.
5
+ #
6
+ # Cloudflare Workers has no filesystem, so Sinatra's `static!` (which
7
+ # calls `File.file?` + `send_file`) returns 404 for every asset.
8
+ # This script reads every file under `public/` at build time, wraps
9
+ # the content in a Ruby Hash constant, and generates a Rack middleware
10
+ # that serves matching paths directly from memory.
11
+ #
12
+ # Usage:
13
+ # ruby bin/compile-assets --input public --output build/homura_assets.rb --namespace HomuraAssets
14
+
15
+ require 'fileutils'
16
+ require 'optparse'
17
+
18
+ MIME_TYPES = {
19
+ '.css' => 'text/css; charset=utf-8',
20
+ '.js' => 'application/javascript; charset=utf-8',
21
+ '.json' => 'application/json; charset=utf-8',
22
+ '.html' => 'text/html; charset=utf-8',
23
+ '.svg' => 'image/svg+xml',
24
+ '.png' => 'image/png',
25
+ '.jpg' => 'image/jpeg',
26
+ '.jpeg' => 'image/jpeg',
27
+ '.gif' => 'image/gif',
28
+ '.ico' => 'image/x-icon',
29
+ '.woff' => 'font/woff',
30
+ '.woff2'=> 'font/woff2',
31
+ '.txt' => 'text/plain; charset=utf-8',
32
+ '.xml' => 'application/xml; charset=utf-8',
33
+ '.webp' => 'image/webp',
34
+ '.map' => 'application/json',
35
+ }.freeze
36
+
37
+ def mime_for(path)
38
+ ext = File.extname(path).downcase
39
+ MIME_TYPES[ext] || 'application/octet-stream'
40
+ end
41
+
42
+ HELP = <<~USAGE
43
+ Usage:
44
+ ruby bin/compile-assets [--input DIR] [--output FILE] [--namespace NAME]
45
+
46
+ Defaults (Phase 15-A homura):
47
+ --input public --output build/homura_assets.rb --namespace HomuraAssets
48
+ USAGE
49
+
50
+ if ARGV.include?('-h') || ARGV.include?('--help')
51
+ puts HELP
52
+ exit 0
53
+ end
54
+
55
+ options = {
56
+ input_dir: 'public',
57
+ output: 'build/homura_assets.rb',
58
+ namespace: 'HomuraAssets'
59
+ }
60
+
61
+ OptionParser.new do |op|
62
+ op.banner = 'Usage: bin/compile-assets [options]'
63
+ op.on('--input DIR', 'Root directory to embed (recursive)') { |d| options[:input_dir] = d }
64
+ op.on('--output PATH', 'Generated Ruby output path') { |p| options[:output] = p }
65
+ op.on('--namespace NAME', 'Ruby module name') { |n| options[:namespace] = n }
66
+ end.parse!
67
+
68
+ public_dir = File.join(Dir.pwd, options[:input_dir])
69
+ unless File.directory?(public_dir)
70
+ warn "compile-assets: no directory #{public_dir.inspect}"
71
+ exit 1
72
+ end
73
+
74
+ ns = options[:namespace]
75
+
76
+ files = Dir.glob(File.join(public_dir, '**', '*')).select { |f| File.file?(f) }.sort
77
+ if files.empty?
78
+ warn 'compile-assets: directory is empty'
79
+ exit 1
80
+ end
81
+
82
+ out_path = options[:output]
83
+ FileUtils.mkdir_p(File.dirname(out_path))
84
+
85
+ File.open(out_path, 'w') do |io|
86
+ io.puts <<~RUBY
87
+ # frozen_string_literal: true
88
+ # Auto-generated by bin/compile-assets — DO NOT EDIT BY HAND.
89
+ #
90
+ # Embeds every file under #{options[:input_dir]}/ as a Ruby constant, then installs a
91
+ # Rack middleware that serves matching paths before they reach Sinatra.
92
+ # This replaces Sinatra's `static!` (which needs a real filesystem)
93
+ # with an in-memory lookup that works on Cloudflare Workers.
94
+
95
+ module #{ns}
96
+ ASSETS = {}
97
+
98
+ class Middleware
99
+ def initialize(app)
100
+ @app = app
101
+ end
102
+
103
+ def call(env)
104
+ path = env['PATH_INFO']
105
+ if (asset = #{ns}::ASSETS[path])
106
+ headers = {
107
+ 'content-type' => asset[:content_type],
108
+ 'content-length' => asset[:body].bytesize.to_s,
109
+ 'cache-control' => 'public, max-age=3600',
110
+ }
111
+ [200, headers, [asset[:body]]]
112
+ else
113
+ @app.call(env)
114
+ end
115
+ end
116
+ end
117
+ end
118
+ RUBY
119
+
120
+ files.each do |full_path|
121
+ rel = full_path.sub(public_dir, '') # e.g. "/style.css"
122
+ content = File.read(full_path)
123
+ ct = mime_for(full_path)
124
+
125
+ io.puts
126
+ io.puts "# #{rel} (#{content.bytesize} bytes)"
127
+ io.puts "#{ns}::ASSETS[#{rel.inspect}] = {"
128
+ io.puts " content_type: #{ct.inspect},"
129
+ io.puts " body: #{content.inspect}"
130
+ io.puts "}"
131
+ end
132
+
133
+ io.puts <<~RUBY
134
+
135
+ # Auto-install the middleware on Sinatra::Base if Sinatra is loaded.
136
+ if defined?(::Sinatra::Base)
137
+ ::Sinatra::Base.use #{ns}::Middleware
138
+ end
139
+ RUBY
140
+ end
141
+
142
+ warn "compile-assets: wrote #{out_path} (#{files.size} assets, #{File.size(out_path)} bytes)"