homura-runtime 0.1.1 → 0.1.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e6be075dbd944ef333e509df1dce30614231a25eb96e8384fcce5ed5e6e3e58c
4
- data.tar.gz: 908dd2b9cc441b2b45946446a7e011deffb94bf2f5f16f649fc0a5c0ee352584
3
+ metadata.gz: 328d7e98210fb60bb229a46166aa605b2c9e2a85baca8418cac31a0ac3688311
4
+ data.tar.gz: 3e3c0562902a031343eddf788a078d1556649c286ec79e44295626a0be2058a3
5
5
  SHA512:
6
- metadata.gz: 32bb2576d6a2b1cde36f0cc4cac86fac4e797e3628714564d9c337b0c18ebb36166f53f6008e708f36ce250423f6da01dbbae1ed3b6d5c2dc1dcc5acd0643386
7
- data.tar.gz: 02166fe7dcb152895f9af50163a7a80bf2c9e3cabdb5d16de7fbd0712bcbf26731264b17b4d7819444ee71034dfce7db5bcb51211d0ae5b7983098b008e7fed8
6
+ metadata.gz: 971c5697869ed0ecc0ba57d6aa5263c6f3b36f5e2bfa0c5e6efc67efe5bb5aade01dd0b522aaa685c4226074924b5a6e8269135fd6a12bdf1f8a6592d3b120cb
7
+ data.tar.gz: 0ef026fa796bfedb3960b42c1f2ea6a09a1d382d9883d16fd744767d3967a3649f0ee9cb322e80f1ddff6db752f05f12da94c62b04c238fe8f43aa4143b667d8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.3 (2026-04-23)
4
+
5
+ - Fix binary static asset embedding so image responses preserve exact bytes on
6
+ Workers instead of being mangled through text encoding.
7
+ - Add regression coverage for binary-vs-text compile-assets output.
8
+ - Convert shipped mascot/icon assets to real PNG payloads so their bytes match
9
+ their `.png` filenames and `image/png` content type.
10
+
11
+ ## 0.1.2 (2026-04-23)
12
+
13
+ - Package the runtime's Opal compile-time vendor shims (`digest`, `zlib`,
14
+ `tempfile`, `tilt`, `rubygems/version`) inside the gem.
15
+ - Teach `cloudflare-workers-build --standalone` to add packaged gem `vendor/`
16
+ directories to the Opal load path, so published gems no longer depend on the
17
+ monorepo root `vendor/`.
3
18
  ## 0.1.1 (2026-04-23)
4
19
 
5
20
  - Fix `cloudflare-workers-build --standalone` and `exe/auto-await` to resolve only
@@ -27,6 +27,10 @@ module CloudflareWorkersBuild
27
27
  def gem_lib(*names)
28
28
  CloudflareWorkers::BuildSupport.gem_lib(*names)
29
29
  end
30
+
31
+ def gem_vendor(*names)
32
+ CloudflareWorkers::BuildSupport.gem_vendor(*names)
33
+ end
30
34
  end
31
35
  end
32
36
 
@@ -114,11 +118,18 @@ def run_opal_standalone!(root, opal_input, opal_output, with_db:)
114
118
  load_paths = []
115
119
  hv = homura_vendor_from_gemfile(root)
116
120
  load_paths << hv.to_s if hv
117
- load_paths += [
118
- 'build/auto_await/app', 'app',
119
- CloudflareWorkersBuild.gem_lib(CloudflareWorkers::BuildSupport::RUNTIME_GEM_NAME),
120
- CloudflareWorkersBuild.gem_lib(CloudflareWorkers::BuildSupport::SINATRA_GEM_NAME)
121
- ]
121
+ runtime_name = CloudflareWorkers::BuildSupport::RUNTIME_GEM_NAME
122
+ sinatra_name = CloudflareWorkers::BuildSupport::SINATRA_GEM_NAME
123
+
124
+ load_paths += ['build/auto_await/app', 'app']
125
+ [
126
+ CloudflareWorkersBuild.gem_lib(runtime_name),
127
+ CloudflareWorkersBuild.gem_vendor(runtime_name),
128
+ CloudflareWorkersBuild.gem_lib(sinatra_name),
129
+ CloudflareWorkersBuild.gem_vendor(sinatra_name)
130
+ ].compact.each do |path|
131
+ load_paths << path
132
+ end
122
133
  load_paths << CloudflareWorkersBuild.gem_lib('sequel-d1') if with_db
123
134
  load_paths << 'vendor' if root.join('vendor').directory?
124
135
  load_paths << 'build'
data/exe/compile-assets CHANGED
@@ -12,6 +12,7 @@
12
12
  # Usage:
13
13
  # ruby bin/compile-assets --input public --output build/homura_assets.rb --namespace HomuraAssets
14
14
 
15
+ require 'base64'
15
16
  require 'fileutils'
16
17
  require 'optparse'
17
18
 
@@ -39,6 +40,13 @@ def mime_for(path)
39
40
  MIME_TYPES[ext] || 'application/octet-stream'
40
41
  end
41
42
 
43
+ def binary_content_type?(content_type)
44
+ !(content_type.start_with?('text/') ||
45
+ content_type.include?('javascript') ||
46
+ content_type.include?('json') ||
47
+ content_type.include?('xml'))
48
+ end
49
+
42
50
  HELP = <<~USAGE
43
51
  Usage:
44
52
  ruby bin/compile-assets [--input DIR] [--output FILE] [--namespace NAME]
@@ -105,10 +113,19 @@ File.open(out_path, 'w') do |io|
105
113
  if (asset = #{ns}::ASSETS[path])
106
114
  headers = {
107
115
  'content-type' => asset[:content_type],
108
- 'content-length' => asset[:body].bytesize.to_s,
109
116
  'cache-control' => 'public, max-age=3600',
110
117
  }
111
- [200, headers, [asset[:body]]]
118
+ if asset[:binary]
119
+ body = ::Cloudflare::EmbeddedBinaryBody.new(
120
+ asset[:body_base64],
121
+ asset[:content_type],
122
+ headers['cache-control']
123
+ )
124
+ [200, headers, [body.raw_response(200, headers)]]
125
+ else
126
+ headers['content-length'] = asset[:body].bytesize.to_s
127
+ [200, headers, [asset[:body]]]
128
+ end
112
129
  else
113
130
  @app.call(env)
114
131
  end
@@ -119,14 +136,20 @@ File.open(out_path, 'w') do |io|
119
136
 
120
137
  files.each do |full_path|
121
138
  rel = full_path.sub(public_dir, '') # e.g. "/style.css"
122
- content = File.read(full_path)
139
+ content = File.binread(full_path)
123
140
  ct = mime_for(full_path)
141
+ binary = binary_content_type?(ct)
124
142
 
125
143
  io.puts
126
144
  io.puts "# #{rel} (#{content.bytesize} bytes)"
127
145
  io.puts "#{ns}::ASSETS[#{rel.inspect}] = {"
128
146
  io.puts " content_type: #{ct.inspect},"
129
- io.puts " body: #{content.inspect}"
147
+ io.puts " binary: #{binary},"
148
+ if binary
149
+ io.puts " body_base64: #{Base64.strict_encode64(content).inspect}"
150
+ else
151
+ io.puts " body: #{content.inspect}"
152
+ end
130
153
  io.puts "}"
131
154
  end
132
155
 
@@ -12,6 +12,13 @@ module CloudflareWorkers
12
12
  loaded_specs[name]
13
13
  end
14
14
 
15
+ def gem_root(name, loaded_specs: Gem.loaded_specs)
16
+ spec = loaded_spec(name, loaded_specs: loaded_specs)
17
+ return spec.full_gem_path if spec
18
+
19
+ raise("cloudflare-workers-build: gem #{name} not loaded; use bundle exec from app root")
20
+ end
21
+
15
22
  def runtime_root(current_file:, loaded_specs: Gem.loaded_specs)
16
23
  spec = loaded_spec(RUNTIME_GEM_NAME, loaded_specs: loaded_specs)
17
24
  return Pathname(spec.full_gem_path) if spec
@@ -20,10 +27,14 @@ module CloudflareWorkers
20
27
  end
21
28
 
22
29
  def gem_lib(name, loaded_specs: Gem.loaded_specs)
23
- spec = loaded_spec(name, loaded_specs: loaded_specs)
24
- return File.join(spec.full_gem_path, 'lib') if spec
30
+ File.join(gem_root(name, loaded_specs: loaded_specs), 'lib')
31
+ end
25
32
 
26
- raise("cloudflare-workers-build: gem #{name} not loaded; use bundle exec from app root")
33
+ def gem_vendor(name, loaded_specs: Gem.loaded_specs)
34
+ vendor = File.join(gem_root(name, loaded_specs: loaded_specs), 'vendor')
35
+ return vendor if Dir.exist?(vendor)
36
+
37
+ nil
27
38
  end
28
39
 
29
40
  def vendor_from_gemfile(project_root)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CloudflareWorkers
4
- VERSION = '0.1.1'
4
+ VERSION = '0.1.3'
5
5
  end
@@ -313,6 +313,18 @@ module Rack
313
313
  return `new Response(#{js_stream}, { status: #{status.to_i}, headers: #{js_headers} })`
314
314
  end
315
315
 
316
+ if body.is_a?(::Cloudflare::EmbeddedBinaryBody) || (body.respond_to?(:first) && body.first.is_a?(::Cloudflare::EmbeddedBinaryBody))
317
+ bin = body.is_a?(::Cloudflare::EmbeddedBinaryBody) ? body : body.first
318
+ js_stream = bin.stream
319
+ ct = bin.content_type
320
+ cc = bin.cache_control
321
+ js_headers = `({})`
322
+ headers.each { |k, v| ks = k.to_s; vs = v.to_s; `#{js_headers}[#{ks}] = #{vs}` }
323
+ `#{js_headers}['content-type'] = #{ct}` if ct
324
+ `#{js_headers}['cache-control'] = #{cc}` if cc
325
+ return `new Response(#{js_stream}, { status: #{status.to_i}, headers: #{js_headers} })`
326
+ end
327
+
316
328
  # Phase 10 — Workers AI streaming: a Cloudflare::AI::Stream wraps
317
329
  # a JS ReadableStream<Uint8Array> emitting SSE-formatted bytes
318
330
  # ("data: {json}\n\n"). Pass it straight through so the client
@@ -530,6 +542,35 @@ module Cloudflare
530
542
  def close; end
531
543
  end
532
544
 
545
+ # EmbeddedBinaryBody carries a base64-encoded asset payload produced at
546
+ # build time by `compile-assets`, then reconstructs a Uint8Array in the
547
+ # Worker before building the Response stream.
548
+ class EmbeddedBinaryBody
549
+ attr_reader :body_base64, :content_type, :cache_control
550
+
551
+ def initialize(body_base64, content_type = 'application/octet-stream', cache_control = nil)
552
+ @body_base64 = body_base64 || ''
553
+ @content_type = content_type
554
+ @cache_control = cache_control
555
+ end
556
+
557
+ def each; end
558
+
559
+ def close; end
560
+
561
+ def raw_response(status, headers = {})
562
+ js_headers = `({})`
563
+ headers.each { |k, v| ks = k.to_s; vs = v.to_s; `#{js_headers}[#{ks}] = #{vs}` }
564
+ `#{js_headers}['content-type'] = #{@content_type}` if @content_type
565
+ `#{js_headers}['cache-control'] = #{@cache_control}` if @cache_control
566
+ RawResponse.new(`new Response(#{stream}, { status: #{status.to_i}, headers: #{js_headers} })`)
567
+ end
568
+
569
+ def stream
570
+ `(function(b64) { return new ReadableStream({ start(controller) { var bin = globalThis.atob(b64); var len = bin.length; var out = new Uint8Array(len); for (var i = 0; i < len; i++) { out[i] = bin.charCodeAt(i) & 0xff; } controller.enqueue(out); controller.close(); } }); })(#{@body_base64})`
571
+ end
572
+ end
573
+
533
574
  # NOTE: the single-line backtick `...` form is used below instead of the
534
575
  # multi-line `%x{ ... }` or multi-line backtick form. Opal's compiler
535
576
  # treats a *multi-line* x-string as a raw statement and refuses to use
@@ -0,0 +1,4 @@
1
+ # Stub for require 'cgi/escape'.
2
+ # Opal stdlib provides `cgi` as a single file containing CGI.escape /
3
+ # CGI.unescape; the upstream `cgi/escape` subfile does not exist.
4
+ require 'cgi'
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+ # Compatibility shim — `digest/sha2` traditionally pulls in
3
+ # Digest::SHA256/SHA384/SHA512. Phase 7 defines them in vendor/digest.rb,
4
+ # so we just re-require digest and rely on the existing constants.
5
+ require 'digest'
data/vendor/digest.rb ADDED
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+ # backtick_javascript: true
3
+
4
+ require 'corelib/array/pack'
5
+ require 'corelib/string/unpack'
6
+ #
7
+ # Phase 7 — Real Digest implementation backed by node:crypto.
8
+ #
9
+ # Replaces the Phase 2 NotImplementedError stub. All hash algos are
10
+ # synchronous (no Promise glue), enabled by importing node:crypto via
11
+ # src/setup-node-crypto.mjs and exposing it on globalThis.
12
+ #
13
+ # Available on:
14
+ # - Cloudflare Workers (with `compatibility_flags = ["nodejs_compat"]`)
15
+ # - Node.js (with `node --import ./src/setup-node-crypto.mjs`)
16
+ #
17
+ # Surface mirrors CRuby's `digest/sha1`, `digest/sha2`, `digest/md5`:
18
+ #
19
+ # Digest::SHA256.hexdigest(str) # one-shot hex
20
+ # Digest::SHA256.digest(str) # one-shot binary
21
+ # Digest::SHA256.new.update(s).hexdigest # streaming
22
+
23
+ module Digest
24
+ # Common base class shared by SHA1 / SHA256 / SHA384 / SHA512 / MD5.
25
+ # Subclasses define ALGO (the node:crypto algorithm name).
26
+ class Base
27
+ def initialize
28
+ reset
29
+ end
30
+
31
+ def reset
32
+ @hasher = `globalThis.__nodeCrypto__.createHash(#{self.class::ALGO})`
33
+ self
34
+ end
35
+
36
+ def update(data)
37
+ str = data.to_s
38
+ `#{@hasher}.update(#{str}, 'utf8')`
39
+ self
40
+ end
41
+ alias_method :<<, :update
42
+
43
+ def hexdigest
44
+ hasher = @hasher
45
+ `#{hasher}.copy().digest('hex')`
46
+ end
47
+
48
+ def digest
49
+ hex = hexdigest
50
+ [hex].pack('H*')
51
+ end
52
+
53
+ def base64digest
54
+ hasher = @hasher
55
+ `#{hasher}.copy().digest('base64')`
56
+ end
57
+
58
+ def to_s
59
+ hexdigest
60
+ end
61
+
62
+ def self.hexdigest(data)
63
+ algo = self::ALGO
64
+ str = data.to_s
65
+ `globalThis.__nodeCrypto__.createHash(#{algo}).update(#{str}, 'utf8').digest('hex')`
66
+ end
67
+
68
+ def self.digest(data)
69
+ hex = hexdigest(data)
70
+ [hex].pack('H*')
71
+ end
72
+
73
+ def self.base64digest(data)
74
+ algo = self::ALGO
75
+ str = data.to_s
76
+ `globalThis.__nodeCrypto__.createHash(#{algo}).update(#{str}, 'utf8').digest('base64')`
77
+ end
78
+
79
+ # Disk-backed digest is impossible on Workers (no FS).
80
+ def self.file(*)
81
+ raise NotImplementedError, 'Digest.file is unavailable on Cloudflare Workers (no filesystem)'
82
+ end
83
+ end
84
+
85
+ # Backwards-compat alias for code that does `class Foo < Digest::Class`.
86
+ Class = Base unless const_defined?(:Class)
87
+
88
+ class SHA1 < Base; ALGO = 'sha1'.freeze; end
89
+ class SHA256 < Base; ALGO = 'sha256'.freeze; end
90
+ class SHA384 < Base; ALGO = 'sha384'.freeze; end
91
+ class SHA512 < Base; ALGO = 'sha512'.freeze; end
92
+ class MD5 < Base; ALGO = 'md5'.freeze; end
93
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # homura Opal stub for Gem::Version.
4
+ #
5
+ # Upstream Sinatra 4.x uses `Gem::Version.new(RUBY_VERSION) >=
6
+ # Gem::Version.new("3.0")` to conditionally activate the `except`
7
+ # override on IndifferentHash. Opal does not bundle RubyGems, so we
8
+ # provide a tiny comparator that parses dotted versions as Integer
9
+ # arrays (sufficient for the Sinatra use-case).
10
+
11
+ module Gem
12
+ class Version
13
+ include Comparable
14
+
15
+ attr_reader :parts
16
+
17
+ def initialize(str)
18
+ @parts = str.to_s.split('.').map { |s| s.to_i rescue 0 }
19
+ end
20
+
21
+ def <=>(other)
22
+ return nil unless other.is_a?(Version)
23
+ i = 0
24
+ max = [parts.size, other.parts.size].max
25
+ while i < max
26
+ a = parts[i] || 0
27
+ b = other.parts[i] || 0
28
+ cmp = a <=> b
29
+ return cmp unless cmp == 0
30
+ i += 1
31
+ end
32
+ 0
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,34 @@
1
+ # Minimal Tempfile stub for homura Phase 2.
2
+ # Cloudflare Workers do not have a writable filesystem so a real Tempfile
3
+ # implementation is impossible. Rack only references Tempfile for
4
+ # multipart upload buffering, which is not exercised by the hello-world
5
+ # handler. This stub allows `require 'tempfile'` to succeed; calling the
6
+ # class will raise an explicit error.
7
+
8
+ require 'stringio'
9
+
10
+ class Tempfile < StringIO
11
+ def initialize(*)
12
+ super('')
13
+ end
14
+
15
+ def self.open(*)
16
+ raise NotImplementedError, 'Tempfile is stubbed in homura Phase 2 (Workers have no writable FS)'
17
+ end
18
+
19
+ def path
20
+ raise NotImplementedError, 'Tempfile#path stubbed (no FS)'
21
+ end
22
+
23
+ def unlink
24
+ self
25
+ end
26
+
27
+ def delete
28
+ self
29
+ end
30
+
31
+ def close!
32
+ close
33
+ end
34
+ end
data/vendor/tilt.rb ADDED
@@ -0,0 +1,75 @@
1
+ # Minimal Tilt stub for the homura Phase 2 hello-world handler.
2
+ #
3
+ # Real Sinatra requires the actual `tilt` gem for template rendering
4
+ # (ERB, Haml, etc.). janbiedermann does not maintain a tilt fork because
5
+ # Tilt's internals (binding manipulation, file IO) are difficult to run
6
+ # under Opal as-is.
7
+ #
8
+ # For Phase 2 we do not render any templates — `get '/' do; "hello"; end`
9
+ # returns a String directly and never reaches Tilt. So we provide just
10
+ # enough of the Tilt surface area for `require 'tilt'` to succeed and
11
+ # for Sinatra::Base to load. If a request actually tries to render a
12
+ # template, it will raise an explicit NotImplementedError so the gap is
13
+ # obvious and we can grow this stub deliberately in a later phase.
14
+
15
+ module Tilt
16
+ class TemplateNotFound < StandardError; end
17
+
18
+ class Cache
19
+ def initialize
20
+ @cache = {}
21
+ end
22
+
23
+ def fetch(*key)
24
+ @cache[key] ||= yield
25
+ end
26
+
27
+ def clear
28
+ @cache.clear
29
+ end
30
+ end
31
+
32
+ class Mapping
33
+ def initialize
34
+ @extensions = {}
35
+ end
36
+
37
+ def register(template_class, *extensions)
38
+ extensions.each { |ext| @extensions[ext.to_s] = template_class }
39
+ end
40
+
41
+ def [](name)
42
+ @extensions[name.to_s]
43
+ end
44
+
45
+ def extensions_for(engine_or_class)
46
+ @extensions.each_with_object([]) do |(ext, klass), out|
47
+ out << ext if klass == engine_or_class
48
+ end
49
+ end
50
+
51
+ def template_for(name)
52
+ @extensions[name.to_s]
53
+ end
54
+ end
55
+
56
+ class << self
57
+ def default_mapping
58
+ @default_mapping ||= Mapping.new
59
+ end
60
+
61
+ def [](name)
62
+ default_mapping[name]
63
+ end
64
+
65
+ def register(template_class, *extensions)
66
+ default_mapping.register(template_class, *extensions)
67
+ end
68
+
69
+ def new(file = nil, line = nil, options = {}, &block)
70
+ raise NotImplementedError,
71
+ 'Tilt template rendering is not available in homura Phase 2 ' \
72
+ '(stubbed). Return Strings or arrays from your Sinatra handlers.'
73
+ end
74
+ end
75
+ end
data/vendor/zlib.rb ADDED
@@ -0,0 +1,41 @@
1
+ # Minimal Zlib stub for the homura Phase 2 hello-world handler.
2
+ # Opal stdlib does not ship a zlib module. Real homura apps that need
3
+ # response compression should rely on the Cloudflare edge to gzip
4
+ # responses on the way out. This stub only exists so that
5
+ # `require 'zlib'` (transitively pulled in by rack/deflater) does not
6
+ # fail at compile time. None of the methods below are reachable from
7
+ # the Phase 2 hello-world path.
8
+
9
+ module Zlib
10
+ class Error < StandardError; end
11
+ class GzipFile
12
+ class Error < Zlib::Error; end
13
+ class CRCError < Error; end
14
+ class LengthError < Error; end
15
+ class NoFooter < Error; end
16
+ end
17
+
18
+ class GzipReader < GzipFile
19
+ def self.wrap(*); raise NotImplementedError, 'Zlib stubbed'; end
20
+ end
21
+
22
+ class GzipWriter < GzipFile
23
+ def self.wrap(*); raise NotImplementedError, 'Zlib stubbed'; end
24
+ end
25
+
26
+ class Deflate
27
+ def self.deflate(*); raise NotImplementedError, 'Zlib stubbed'; end
28
+ def initialize(*); end
29
+ def deflate(*); raise NotImplementedError, 'Zlib stubbed'; end
30
+ def finish; raise NotImplementedError, 'Zlib stubbed'; end
31
+ def close; end
32
+ end
33
+
34
+ class Inflate
35
+ def self.inflate(*); raise NotImplementedError, 'Zlib stubbed'; end
36
+ end
37
+
38
+ DEFAULT_COMPRESSION = -1
39
+ BEST_SPEED = 1
40
+ BEST_COMPRESSION = 9
41
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: homura-runtime
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kazuhiro NISHIYAMA
@@ -76,6 +76,13 @@ files:
76
76
  - runtime/worker_module.mjs
77
77
  - runtime/wrangler.toml.example
78
78
  - templates/wrangler.toml.example
79
+ - vendor/cgi/escape.rb
80
+ - vendor/digest.rb
81
+ - vendor/digest/sha2.rb
82
+ - vendor/rubygems/version.rb
83
+ - vendor/tempfile.rb
84
+ - vendor/tilt.rb
85
+ - vendor/zlib.rb
79
86
  homepage: https://github.com/kazuph/homura
80
87
  licenses:
81
88
  - MIT