ocran 1.4.6 → 1.5.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 +4 -4
- data/CHANGELOG.txt +68 -0
- data/README.md +295 -10
- data/ext/extconf.rb +5 -1
- data/lib/ocran/aot_toolchain.rb +154 -0
- data/lib/ocran/command_output.rb +8 -1
- data/lib/ocran/compatibility_scan.rb +414 -0
- data/lib/ocran/cosmo_toolchain.rb +29 -7
- data/lib/ocran/dir_builder.rb +88 -38
- data/lib/ocran/direction.rb +190 -30
- data/lib/ocran/ed_resource.rb +113 -0
- data/lib/ocran/ed_resource_builder.rb +199 -0
- data/lib/ocran/file_path_set.rb +9 -5
- data/lib/ocran/gem_spec_queryable.rb +7 -3
- data/lib/ocran/host_config_helper.rb +11 -1
- data/lib/ocran/inno_setup_script_builder.rb +24 -14
- data/lib/ocran/launcher_batch_builder.rb +8 -11
- data/lib/ocran/npm_package.rb +106 -0
- data/lib/ocran/option.rb +425 -35
- data/lib/ocran/pe_imports.rb +96 -0
- data/lib/ocran/pe_resources.rb +226 -0
- data/lib/ocran/refine_pathname.rb +29 -32
- data/lib/ocran/roundhouse_builder.rb +316 -0
- data/lib/ocran/runner.rb +43 -11
- data/lib/ocran/runtime_sdk.rb +252 -0
- data/lib/ocran/spinel_builder.rb +183 -0
- data/lib/ocran/spinel_compatibility.rb +149 -0
- data/lib/ocran/static_runtime/boot.rb +443 -0
- data/lib/ocran/static_runtime/build_static_ext.rb +86 -0
- data/lib/ocran/static_runtime_builder.rb +814 -0
- data/lib/ocran/stub_builder.rb +70 -25
- data/lib/ocran/version.rb +1 -1
- data/lib/ocran/wasm_builder.rb +570 -0
- data/lib/ocran/wasm_compatibility.rb +198 -0
- data/lib/ocran/windows_command_escaping.rb +21 -0
- data/lib/ocran/zip_payload_builder.rb +18 -9
- data/lib/ocran/zip_writer.rb +48 -9
- data/src/Makefile +18 -2
- data/src/Makefile.mswin +65 -0
- data/src/inst_dir.c +34 -6
- data/src/path_utils.c +144 -0
- data/src/script_info.c +5 -0
- data/src/stub.c +77 -19
- data/src/stub.rc +1 -1
- data/src/system_utils.c +276 -155
- data/src/system_utils.h +46 -3
- data/src/system_utils_posix.c +243 -148
- data/src/unpack.c +9 -0
- metadata +22 -4
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "pathname"
|
|
6
|
+
require "rbconfig"
|
|
7
|
+
require "tmpdir"
|
|
8
|
+
require_relative "command_output" unless defined?(Ocran::CommandOutput)
|
|
9
|
+
require_relative "aot_toolchain"
|
|
10
|
+
require_relative "npm_package"
|
|
11
|
+
require_relative "wasm_compatibility"
|
|
12
|
+
|
|
13
|
+
module Ocran
|
|
14
|
+
# Builds --wasm output: the application as a web page that runs it in the
|
|
15
|
+
# browser on WebAssembly, written to a directory or a zip archive.
|
|
16
|
+
#
|
|
17
|
+
# Two runtimes:
|
|
18
|
+
#
|
|
19
|
+
# ruby (the default) - ruby.wasm (https://github.com/ruby/ruby.wasm),
|
|
20
|
+
# CRuby with its whole standard library compiled to WebAssembly. The
|
|
21
|
+
# application's files are packed into the interpreter's module with
|
|
22
|
+
# rbwasm (gem install ruby_wasm), at /src. The interpreter is the
|
|
23
|
+
# prebuilt one from npm, and the gems of the application's Gemfile are
|
|
24
|
+
# packed in beside the sources, at /gems, with a setup file that puts
|
|
25
|
+
# them on the load path in place of bundler/setup; that takes seconds.
|
|
26
|
+
# Only a gem with a C extension needs `rbwasm build`, which compiles
|
|
27
|
+
# CRuby and the gems for WebAssembly once and caches it.
|
|
28
|
+
#
|
|
29
|
+
# picoruby - PicoRuby (https://github.com/picoruby/picoruby), the mruby
|
|
30
|
+
# based Ruby for microcontrollers, whose WebAssembly build is 2 MB
|
|
31
|
+
# rather than 30 and needs no tool at all: its runtime comes from npm
|
|
32
|
+
# and runs the script from a <script type="text/ruby"> tag. PicoRuby has
|
|
33
|
+
# no file system holding the application in the browser, so OCRAN
|
|
34
|
+
# bundles the program's own files into one script.
|
|
35
|
+
#
|
|
36
|
+
# Either way the result is a static site: index.html, the runtime and the
|
|
37
|
+
# application. Browsers fetch WebAssembly only over HTTP, so it has to be
|
|
38
|
+
# served rather than opened as a file.
|
|
39
|
+
class WasmBuilder
|
|
40
|
+
include CommandOutput
|
|
41
|
+
|
|
42
|
+
# Versions of CRuby that ruby.wasm publishes prebuilt.
|
|
43
|
+
RUBY_VERSIONS = %w[4.0 3.4 3.3 3.2].freeze
|
|
44
|
+
|
|
45
|
+
# Used when the ruby_wasm gem's own version cannot be determined. The
|
|
46
|
+
# JavaScript runtime, the prebuilt interpreter and rbwasm come from the
|
|
47
|
+
# same ruby.wasm release, so the gem's version picks the npm packages.
|
|
48
|
+
DEFAULT_RUBY_WASM_VERSION = "2.10.1"
|
|
49
|
+
|
|
50
|
+
PICORUBY_VERSION = ENV.fetch("OCRAN_PICORUBY_VERSION", "4.0.5")
|
|
51
|
+
PICORUBY_FILES = %w[dist/init.iife.js dist/picoruby.js dist/picoruby.wasm].freeze
|
|
52
|
+
|
|
53
|
+
MAX_OUTPUT_LINES = 60
|
|
54
|
+
|
|
55
|
+
def initialize(option)
|
|
56
|
+
@option = option
|
|
57
|
+
@script = option.script
|
|
58
|
+
@name = @script.basename(".*").to_s
|
|
59
|
+
@output = option.wasm_output
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def build
|
|
63
|
+
Dir.mktmpdir("ocran-wasm") do |work|
|
|
64
|
+
site = File.join(work, "site")
|
|
65
|
+
FileUtils.mkdir_p(site)
|
|
66
|
+
|
|
67
|
+
if @option.wasm_runtime == :picoruby
|
|
68
|
+
build_picoruby(site)
|
|
69
|
+
else
|
|
70
|
+
build_ruby_wasm(work, site)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
if @option.wasm_zip?
|
|
74
|
+
write_zip(site)
|
|
75
|
+
else
|
|
76
|
+
write_directory(site)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# --- ruby.wasm ------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def build_ruby_wasm(work, site)
|
|
86
|
+
gemfile = @option.application_gemfile
|
|
87
|
+
bundle = gemfile ? resolve_bundle(gemfile) : []
|
|
88
|
+
analysis = RubyWasmCompatibility.new(@script, bundle: gemfile && bundle.map { |gem| bundle_spec(gem) },
|
|
89
|
+
project_dirs: project_dirs).analyze
|
|
90
|
+
|
|
91
|
+
rbwasm = find_rbwasm
|
|
92
|
+
unless rbwasm
|
|
93
|
+
error "--wasm needs rbwasm, the ruby.wasm packager from the ruby_wasm gem, which was not found."
|
|
94
|
+
STDERR.puts
|
|
95
|
+
STDERR.puts "Install it with:"
|
|
96
|
+
STDERR.puts
|
|
97
|
+
STDERR.puts " gem install ruby_wasm"
|
|
98
|
+
STDERR.puts
|
|
99
|
+
STDERR.puts "(or add `gem \"ruby_wasm\"` to the Gemfile you run OCRAN under). It needs no compiler for"
|
|
100
|
+
STDERR.puts "applications without gems. --wasm=picoruby needs nothing installed at all."
|
|
101
|
+
STDERR.puts
|
|
102
|
+
print_report(analysis, "Meanwhile, a static check of whether #{display(@script)} is likely to run on ruby.wasm:")
|
|
103
|
+
raise "rbwasm is not installed"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
warn_about(analysis)
|
|
107
|
+
ruby_version = wasm_ruby_version
|
|
108
|
+
npm_version = ruby_wasm_version
|
|
109
|
+
verbose "Using rbwasm #{npm_version} at #{rbwasm.last}, Ruby #{ruby_version}"
|
|
110
|
+
|
|
111
|
+
native = bundle.select { |gem| gem["native"] }
|
|
112
|
+
dirs = []
|
|
113
|
+
if native.empty?
|
|
114
|
+
say "Using the prebuilt ruby.wasm (Ruby #{ruby_version} with its standard library)"
|
|
115
|
+
base = NpmPackage.fetch("@ruby/#{ruby_version}-wasm-wasi", npm_version, ["dist/ruby+stdlib.wasm"]) { |s| say s }
|
|
116
|
+
.fetch("dist/ruby+stdlib.wasm")
|
|
117
|
+
setup = nil
|
|
118
|
+
unless bundle.empty?
|
|
119
|
+
say "Packing the gems of #{display(gemfile)} into the module (#{bundle.map { |g| g["name"] }.join(", ")})"
|
|
120
|
+
gems = File.join(work, "gems")
|
|
121
|
+
stage_gems(gems, bundle)
|
|
122
|
+
dirs.push("--dir", "#{gems}::#{GEMS_MOUNT}")
|
|
123
|
+
setup = GEMS_SETUP
|
|
124
|
+
end
|
|
125
|
+
else
|
|
126
|
+
base = build_with_gems(rbwasm, gemfile, ruby_version, work, analysis, native.map { |g| g["name"] })
|
|
127
|
+
setup = "/bundle/setup"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
src = File.join(work, "src")
|
|
131
|
+
main = stage_sources(src, analysis)
|
|
132
|
+
say "Packing #{display(@script)} into the WebAssembly module"
|
|
133
|
+
run_or_fail([*rbwasm, "pack", base, "--dir", "#{src}::/src", *dirs, "-o", File.join(site, "app.wasm")],
|
|
134
|
+
"rbwasm could not pack the application", analysis)
|
|
135
|
+
|
|
136
|
+
runtime = NpmPackage.fetch("@ruby/wasm-wasi", npm_version, ["dist/browser.umd.js"]) { |s| say s }
|
|
137
|
+
FileUtils.cp(runtime.fetch("dist/browser.umd.js"), File.join(site, "browser.umd.js"))
|
|
138
|
+
File.write(File.join(site, "index.html"), ruby_wasm_html("/src/#{main}", setup: setup))
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Where the gems are packed inside the module, and the file that puts
|
|
142
|
+
# them on the load path. It is required in place of bundler/setup,
|
|
143
|
+
# which has no Gemfile to read in the browser; a shim makes the
|
|
144
|
+
# application's own `require "bundler/setup"` a no-op as well.
|
|
145
|
+
GEMS_MOUNT = "/gems"
|
|
146
|
+
GEMS_SETUP = "/gems/setup.rb"
|
|
147
|
+
|
|
148
|
+
# Directories of a gem that an application never loads.
|
|
149
|
+
GEM_SKIP = %w[test spec features benchmark benchmarks .git .github].freeze
|
|
150
|
+
|
|
151
|
+
# Ruby run in a child process to list the gems of BUNDLE_GEMFILE as
|
|
152
|
+
# Bundler resolves them: every gem of the lock, with where it is
|
|
153
|
+
# installed. Bundler is asked in a child so that it never loads here.
|
|
154
|
+
BUNDLE_QUERY = <<~RUBY
|
|
155
|
+
require "bundler"
|
|
156
|
+
require "json"
|
|
157
|
+
specs = Bundler.definition.specs.reject { |s| s.name == "bundler" }
|
|
158
|
+
puts JSON.generate(specs.map { |s|
|
|
159
|
+
default = s.respond_to?(:default_gem?) && s.default_gem?
|
|
160
|
+
{ "name" => s.name, "version" => s.version.to_s, "path" => s.full_gem_path,
|
|
161
|
+
"require_paths" => s.full_require_paths, "default" => default,
|
|
162
|
+
"native" => !s.extensions.empty? || s.platform.to_s != "ruby" }
|
|
163
|
+
})
|
|
164
|
+
RUBY
|
|
165
|
+
private_constant :BUNDLE_QUERY
|
|
166
|
+
|
|
167
|
+
# The gems the application's Gemfile.lock resolves to, as hashes (see
|
|
168
|
+
# BUNDLE_QUERY), without the default gems: ruby.wasm's standard library
|
|
169
|
+
# has those already.
|
|
170
|
+
def resolve_bundle(gemfile)
|
|
171
|
+
lockfile = Pathname("#{gemfile}.lock")
|
|
172
|
+
lockfile = gemfile.dirname + "gems.locked" if gemfile.basename.to_s == "gems.rb"
|
|
173
|
+
return [] unless lockfile.file?
|
|
174
|
+
|
|
175
|
+
env = { "BUNDLE_GEMFILE" => gemfile.to_s, "RUBYOPT" => nil, "BUNDLER_SETUP" => nil, "BUNDLE_LOCKFILE" => nil }
|
|
176
|
+
output, status = Open3.capture2e(env, RbConfig.ruby, "-e", BUNDLE_QUERY, chdir: File.dirname(gemfile))
|
|
177
|
+
unless status.success?
|
|
178
|
+
STDERR.puts "Bundler could not resolve the gems of #{display(gemfile)}:"
|
|
179
|
+
STDERR.puts output.lines.last(MAX_OUTPUT_LINES).map { |l| " #{l}" }.join
|
|
180
|
+
STDERR.puts
|
|
181
|
+
STDERR.puts "The gems have to be installed for this Ruby: run `bundle install` in #{display(File.dirname(gemfile))}."
|
|
182
|
+
raise "the application's gems could not be resolved"
|
|
183
|
+
end
|
|
184
|
+
JSON.parse(output.lines.last).reject { |gem| gem["default"] }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Copies the gems into dir, each under name-version, and writes the
|
|
188
|
+
# setup file that puts their require paths on the load path.
|
|
189
|
+
def stage_gems(dir, bundle)
|
|
190
|
+
load_paths = bundle.flat_map do |gem|
|
|
191
|
+
home = File.join(dir, "#{gem["name"]}-#{gem["version"]}")
|
|
192
|
+
copy_gem(gem["path"], home)
|
|
193
|
+
gem["require_paths"].map do |path|
|
|
194
|
+
rel = Pathname(path).relative_path_from(Pathname(gem["path"])).to_s
|
|
195
|
+
File.join(GEMS_MOUNT, File.basename(home), rel == "." ? "" : rel).chomp("/")
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
shim = File.join(dir, ".shims", "bundler")
|
|
200
|
+
FileUtils.mkdir_p(shim)
|
|
201
|
+
File.write(File.join(shim, "setup.rb"), "# The gems are on the load path already; see #{GEMS_SETUP}.\n")
|
|
202
|
+
File.write(File.join(dir, "setup.rb"), <<~RUBY)
|
|
203
|
+
# Written by OCRAN: the application's gems, packed at #{GEMS_MOUNT}, take the
|
|
204
|
+
# place of the bundle. Required before the program instead of bundler/setup.
|
|
205
|
+
$LOAD_PATH.unshift(#{(load_paths + [File.join(GEMS_MOUNT, ".shims")]).map(&:inspect).join(", ")})
|
|
206
|
+
RUBY
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def copy_gem(from, to)
|
|
210
|
+
FileUtils.mkdir_p(to)
|
|
211
|
+
Dir.children(from).each do |entry|
|
|
212
|
+
next if GEM_SKIP.include?(entry)
|
|
213
|
+
|
|
214
|
+
FileUtils.cp_r(File.join(from, entry), File.join(to, entry))
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# `rbwasm build` compiles CRuby for WebAssembly together with the gems
|
|
219
|
+
# the Gemfile lists, under /bundle. Its downloads and build trees are
|
|
220
|
+
# kept in OCRAN's cache, so only the first build is slow.
|
|
221
|
+
def build_with_gems(rbwasm, gemfile, ruby_version, work, analysis, native)
|
|
222
|
+
say "Building ruby.wasm with the gems of #{display(gemfile)}: #{native.join(", ")} " \
|
|
223
|
+
"#{native.size == 1 ? "has" : "have"} a C extension, which the prebuilt interpreter cannot load"
|
|
224
|
+
say "The first build compiles CRuby for WebAssembly and takes several minutes; later builds reuse it"
|
|
225
|
+
|
|
226
|
+
cache = File.join(cache_dir, "ruby_wasm")
|
|
227
|
+
FileUtils.mkdir_p(cache)
|
|
228
|
+
output = File.join(work, "ruby.wasm")
|
|
229
|
+
env = { "RUBY_WASM_ROOT" => cache, "BUNDLE_GEMFILE" => gemfile.to_s,
|
|
230
|
+
"RUBYOPT" => nil, "BUNDLER_SETUP" => nil, "BUNDLE_LOCKFILE" => nil }
|
|
231
|
+
# rbwasm reads the bundle through Bundler only when Bundler is loaded,
|
|
232
|
+
# which `bundle exec` would do - but that needs ruby_wasm in the
|
|
233
|
+
# application's own Gemfile.
|
|
234
|
+
command = [*rbwasm_with_bundler(rbwasm), "build", "--ruby-version", ruby_version, *@option.wasm_options, "-o", output]
|
|
235
|
+
run_or_fail(command, "rbwasm could not build ruby.wasm with the application's gems", analysis,
|
|
236
|
+
env: env, chdir: File.dirname(gemfile)) do
|
|
237
|
+
STDERR.puts " - the gems have to be installed for this Ruby: run `bundle install` in #{display(File.dirname(gemfile))}"
|
|
238
|
+
native = analysis.gems.values.select(&:native).map(&:name)
|
|
239
|
+
unless native.empty?
|
|
240
|
+
STDERR.puts " - gems with C extensions are compiled for WASI and often fail there: #{native.join(", ")}"
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
output
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# A specification standing for a gem of the bundle, for the scan: the
|
|
247
|
+
# gem's name, version, require paths and whether it has an extension.
|
|
248
|
+
def bundle_spec(gem)
|
|
249
|
+
home = Pathname(gem["path"])
|
|
250
|
+
Gem::Specification.new do |s|
|
|
251
|
+
s.name = gem["name"]
|
|
252
|
+
s.version = gem["version"]
|
|
253
|
+
s.require_paths = gem["require_paths"].map { |path| Pathname(path).relative_path_from(home).to_s }
|
|
254
|
+
s.extensions = ["ext/extconf.rb"] if gem["native"]
|
|
255
|
+
end.tap { |s| s.full_gem_path = gem["path"] }
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# The rbwasm command: the RBWASM environment variable, else through this
|
|
259
|
+
# Ruby when the ruby_wasm gem is installed for it, else from PATH.
|
|
260
|
+
def find_rbwasm
|
|
261
|
+
if (explicit = ENV["RBWASM"]) && !explicit.empty?
|
|
262
|
+
raise "RBWASM=#{explicit} is not an executable" unless AotToolchain.executable?(explicit)
|
|
263
|
+
|
|
264
|
+
return [explicit]
|
|
265
|
+
end
|
|
266
|
+
[RbConfig.ruby, Gem.bin_path("ruby_wasm", "rbwasm")]
|
|
267
|
+
rescue Gem::LoadError, Gem::Exception
|
|
268
|
+
path = AotToolchain.search_path("rbwasm")
|
|
269
|
+
path && [path]
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def rbwasm_with_bundler(rbwasm)
|
|
273
|
+
rbwasm.size == 2 ? [rbwasm[0], "-rbundler", rbwasm[1]] : rbwasm
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Gem::MissingSpecError is a LoadError, not a Gem::Exception.
|
|
277
|
+
def ruby_wasm_version
|
|
278
|
+
Gem::Specification.find_by_name("ruby_wasm").version.to_s
|
|
279
|
+
rescue Gem::LoadError, Gem::Exception
|
|
280
|
+
DEFAULT_RUBY_WASM_VERSION
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# The CRuby version closest to the one OCRAN runs on, so the program
|
|
284
|
+
# meets the Ruby it was written for.
|
|
285
|
+
def wasm_ruby_version
|
|
286
|
+
if (wanted = @option.wasm_ruby)
|
|
287
|
+
return wanted if RUBY_VERSIONS.include?(wanted)
|
|
288
|
+
|
|
289
|
+
raise "ruby.wasm publishes Ruby #{RUBY_VERSIONS.join(", ")}, not #{wanted}"
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
host = RUBY_VERSION.split(".").first(2).join(".")
|
|
293
|
+
RUBY_VERSIONS.include?(host) ? host : RUBY_VERSIONS.first
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Copies the program's files into dir, laid out relative to their
|
|
297
|
+
# common directory, and returns the script's path there: the files
|
|
298
|
+
# given on the command line, and the ones the program requires, which
|
|
299
|
+
# the scan found since the program is not run to find them.
|
|
300
|
+
def stage_sources(dir, analysis)
|
|
301
|
+
root = source_root(analysis.project_files)
|
|
302
|
+
(@option.source_files | analysis.project_files).each do |file|
|
|
303
|
+
rel = file.relative_path_from(root)
|
|
304
|
+
dest = File.join(dir, rel.to_s)
|
|
305
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
306
|
+
FileUtils.cp(file, dest)
|
|
307
|
+
end
|
|
308
|
+
@script.relative_path_from(root).to_s
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def source_root(extra = [])
|
|
312
|
+
dirs = (@option.source_files | extra).map(&:dirname)
|
|
313
|
+
dirs.inject do |common, dir|
|
|
314
|
+
common = common.parent until dir.to_s == common.to_s || dir.to_s.start_with?("#{common.to_s.chomp("/")}/")
|
|
315
|
+
common
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# --- PicoRuby -------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
def build_picoruby(site)
|
|
322
|
+
analysis = PicoRubyCompatibility.new(@script, project_dirs: project_dirs).analyze
|
|
323
|
+
warn_about(analysis)
|
|
324
|
+
|
|
325
|
+
say "Bundling #{display(@script)} for PicoRuby"
|
|
326
|
+
File.write(File.join(site, script_name), bundle_sources(analysis))
|
|
327
|
+
|
|
328
|
+
# Files given besides the code (data, images) are served next to the
|
|
329
|
+
# page, where the program can fetch them through the js library.
|
|
330
|
+
bundled = analysis.files.map(&:to_s)
|
|
331
|
+
root = source_root
|
|
332
|
+
@option.source_files.each do |file|
|
|
333
|
+
next if bundled.include?(file.to_s) || file.extname == ".rb"
|
|
334
|
+
|
|
335
|
+
dest = File.join(site, file.relative_path_from(root).to_s)
|
|
336
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
337
|
+
FileUtils.cp(file, dest)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
runtime = NpmPackage.fetch("@picoruby/wasm-wasi", PICORUBY_VERSION, PICORUBY_FILES) { |s| say s }
|
|
341
|
+
runtime.each_value { |path| FileUtils.cp(path, File.join(site, File.basename(path))) }
|
|
342
|
+
File.write(File.join(site, "index.html"), picoruby_html)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# One script made of the program's own files, each placed before the
|
|
346
|
+
# first file that requires it, the way Ruby would have loaded them, with
|
|
347
|
+
# the requires themselves replaced by nil. Line breaks inside a replaced
|
|
348
|
+
# require are kept, so each file's lines stay where they were.
|
|
349
|
+
def bundle_sources(analysis)
|
|
350
|
+
requires = analysis.local_requires.group_by { |r| r.from.to_s }
|
|
351
|
+
order = []
|
|
352
|
+
visit = lambda do |file, active|
|
|
353
|
+
next if order.include?(file) || active.include?(file)
|
|
354
|
+
|
|
355
|
+
(requires[file] || []).each { |r| visit.call(r.target.to_s, active + [file]) }
|
|
356
|
+
order << file
|
|
357
|
+
end
|
|
358
|
+
visit.call(@script.to_s, [])
|
|
359
|
+
|
|
360
|
+
root = source_root
|
|
361
|
+
order.map do |file|
|
|
362
|
+
source = File.binread(file)
|
|
363
|
+
(requires[file] || []).sort_by(&:start_offset).reverse_each do |r|
|
|
364
|
+
original = source.byteslice(r.start_offset...r.end_offset)
|
|
365
|
+
source = source.byteslice(0, r.start_offset) + "nil" + ("\n" * original.count("\n")) +
|
|
366
|
+
source.byteslice(r.end_offset..)
|
|
367
|
+
end
|
|
368
|
+
source = source.force_encoding(Encoding::UTF_8)
|
|
369
|
+
next source if order.size == 1
|
|
370
|
+
|
|
371
|
+
rel = Pathname(file).relative_path_from(root)
|
|
372
|
+
"# ---- #{rel} ----\n#{source.chomp}\n"
|
|
373
|
+
end.join("\n")
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# The bundled program is served under the script's own name, which is
|
|
377
|
+
# the file name PicoRuby's error messages give.
|
|
378
|
+
def script_name = "#{@name}.rb"
|
|
379
|
+
|
|
380
|
+
# --- Output ---------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
def write_directory(site)
|
|
383
|
+
@output.mkpath
|
|
384
|
+
Dir.children(site).each do |entry|
|
|
385
|
+
FileUtils.rm_rf(@output + entry)
|
|
386
|
+
FileUtils.cp_r(File.join(site, entry), @output + entry)
|
|
387
|
+
end
|
|
388
|
+
finished(@output)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def write_zip(site)
|
|
392
|
+
require_relative "zip_writer"
|
|
393
|
+
|
|
394
|
+
@output.dirname.mkpath
|
|
395
|
+
# An empty archive is just its end-of-central-directory record, which
|
|
396
|
+
# ZipWriter appends the site to.
|
|
397
|
+
File.binwrite(@output, "PK\x05\x06".b + ("\0" * 18).b)
|
|
398
|
+
entries = Dir.glob("**/*", base: site).sort.reject { |rel| File.directory?(File.join(site, rel)) }.map do |rel|
|
|
399
|
+
ZipWriter::Entry.new(name: rel, source: File.join(site, rel), mode: 0o644)
|
|
400
|
+
end
|
|
401
|
+
ZipWriter.append(@output.to_s, entries)
|
|
402
|
+
finished(@output)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def finished(path)
|
|
406
|
+
say "Finished building #{display(path)}"
|
|
407
|
+
dir = @option.wasm_zip? ? "<unpacked folder>" : display(path)
|
|
408
|
+
say "Browsers load WebAssembly only over HTTP: serve the folder and open index.html, e.g."
|
|
409
|
+
say " ruby -run -e httpd #{dir} -p 8000 (needs the webrick gem)"
|
|
410
|
+
say " python3 -m http.server -d #{dir} 8000"
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# --- Diagnostics ----------------------------------------------------
|
|
414
|
+
|
|
415
|
+
# Nothing is known to fail until the browser runs it, so what the scan
|
|
416
|
+
# found is shown as a warning and the build goes on.
|
|
417
|
+
def warn_about(analysis)
|
|
418
|
+
return if analysis.findings.empty? || !@option.warning?
|
|
419
|
+
|
|
420
|
+
runtime = analysis.target
|
|
421
|
+
if analysis.errors.empty?
|
|
422
|
+
warning "#{display(@script)} uses #{analysis.findings.size} thing(s) that may not work on #{runtime}" \
|
|
423
|
+
"#{@option.verbose? ? ":" : " (--verbose lists them)"}"
|
|
424
|
+
verbose analysis.report.gsub(/^(?=.)/, " ")
|
|
425
|
+
else
|
|
426
|
+
warning "#{display(@script)} is unlikely to run on #{runtime} as it is; building anyway:"
|
|
427
|
+
STDERR.puts analysis.report.gsub(/^(?=.)/, " ")
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def print_report(analysis, heading)
|
|
432
|
+
STDERR.puts heading
|
|
433
|
+
STDERR.puts
|
|
434
|
+
STDERR.puts analysis.report.gsub(/^(?=.)/, " ")
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def run_or_fail(command, failure, analysis, env: {}, chdir: Dir.pwd)
|
|
438
|
+
verbose command.join(" ")
|
|
439
|
+
output, status = Open3.capture2e(env, *command, chdir: chdir)
|
|
440
|
+
return if status.success?
|
|
441
|
+
|
|
442
|
+
lines = output.lines
|
|
443
|
+
STDERR.puts "#{failure} (exit status #{status.exitstatus.inspect}):"
|
|
444
|
+
STDERR.puts " ... (#{lines.size - MAX_OUTPUT_LINES} earlier lines omitted)" if lines.size > MAX_OUTPUT_LINES
|
|
445
|
+
STDERR.puts lines.last(MAX_OUTPUT_LINES).map { |l| " #{l}" }.join
|
|
446
|
+
STDERR.puts
|
|
447
|
+
print_report(analysis, "What may stand in the way:") unless analysis.findings.empty?
|
|
448
|
+
STDERR.puts
|
|
449
|
+
STDERR.puts "Things to try:"
|
|
450
|
+
yield if block_given?
|
|
451
|
+
STDERR.puts " - --wasm=picoruby runs smaller programs without any of the toolchain"
|
|
452
|
+
raise failure
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def project_dirs
|
|
456
|
+
[@script.dirname, *@option.source_files.map(&:dirname)].uniq
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def cache_dir
|
|
460
|
+
base = ENV["XDG_CACHE_HOME"]
|
|
461
|
+
base = File.join(Dir.home, ".cache") if base.nil? || base.empty?
|
|
462
|
+
File.join(base, "ocran")
|
|
463
|
+
rescue ArgumentError
|
|
464
|
+
File.join(Dir.tmpdir, "ocran-cache")
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# Relative to the working directory when inside it, else absolute.
|
|
468
|
+
def display(path)
|
|
469
|
+
rel = Pathname(path).relative_path_from(Pathname.pwd).to_s
|
|
470
|
+
rel.start_with?("..") ? path.to_s : rel
|
|
471
|
+
rescue ArgumentError
|
|
472
|
+
path.to_s
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# --- Pages ----------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
# setup: a feature to require before the program, which puts its gems
|
|
478
|
+
# on the load path (GEMS_SETUP, or /bundle/setup after rbwasm build).
|
|
479
|
+
def ruby_wasm_html(main, setup:)
|
|
480
|
+
setup = setup ? %(require #{setup.inspect}\n) : ""
|
|
481
|
+
ruby = <<~RUBY
|
|
482
|
+
#{setup}$0 = #{main.inspect}
|
|
483
|
+
Dir.chdir(File.dirname($0))
|
|
484
|
+
load $0
|
|
485
|
+
RUBY
|
|
486
|
+
page(<<~JS, newline: false)
|
|
487
|
+
<script src="browser.umd.js"></script>
|
|
488
|
+
<script>
|
|
489
|
+
(async () => {
|
|
490
|
+
try {
|
|
491
|
+
const { DefaultRubyVM } = window["ruby-wasm-wasi"];
|
|
492
|
+
const module = await WebAssembly.compileStreaming(fetch("app.wasm"));
|
|
493
|
+
const { vm } = await DefaultRubyVM(module);
|
|
494
|
+
ocranStarted();
|
|
495
|
+
await vm.evalAsync(#{ruby.to_json});
|
|
496
|
+
} catch (error) {
|
|
497
|
+
ocranFailed(error);
|
|
498
|
+
}
|
|
499
|
+
})();
|
|
500
|
+
</script>
|
|
501
|
+
JS
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def picoruby_html
|
|
505
|
+
page(<<~JS, newline: true)
|
|
506
|
+
<script type="text/ruby" src="#{script_name}"></script>
|
|
507
|
+
<script src="init.iife.js"></script>
|
|
508
|
+
<script>
|
|
509
|
+
const ocranWait = setInterval(() => {
|
|
510
|
+
if (window.picorubyModule) { clearInterval(ocranWait); ocranStarted(); }
|
|
511
|
+
}, 50);
|
|
512
|
+
</script>
|
|
513
|
+
JS
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# The page around a runtime: a status line while it loads, and the
|
|
517
|
+
# program's standard output and errors mirrored from the console onto
|
|
518
|
+
# the page, where a terminal program's output is expected. newline:
|
|
519
|
+
# whether each console call is a line (PicoRuby) rather than a chunk of
|
|
520
|
+
# output that brings its own line breaks (ruby.wasm).
|
|
521
|
+
def page(runtime, newline:)
|
|
522
|
+
title = @name.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
523
|
+
<<~HTML
|
|
524
|
+
<!DOCTYPE html>
|
|
525
|
+
<html lang="en">
|
|
526
|
+
<head>
|
|
527
|
+
<meta charset="utf-8">
|
|
528
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
529
|
+
<title>#{title}</title>
|
|
530
|
+
<style>
|
|
531
|
+
:root { color-scheme: light dark; }
|
|
532
|
+
body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; }
|
|
533
|
+
#ocran-status { opacity: 0.7; }
|
|
534
|
+
#ocran-output { margin: 0; white-space: pre-wrap; font-family: ui-monospace, Menlo, Consolas, monospace; }
|
|
535
|
+
#ocran-output .stderr { color: #d33; }
|
|
536
|
+
</style>
|
|
537
|
+
</head>
|
|
538
|
+
<body>
|
|
539
|
+
<div id="ocran-status">Loading #{title}...</div>
|
|
540
|
+
<pre id="ocran-output"></pre>
|
|
541
|
+
<script>
|
|
542
|
+
const ocranOutput = document.getElementById("ocran-output");
|
|
543
|
+
const ocranAppend = (text, stream) => {
|
|
544
|
+
const span = document.createElement("span");
|
|
545
|
+
if (stream) span.className = stream;
|
|
546
|
+
span.textContent = #{newline ? 'text + "\\n"' : "text"};
|
|
547
|
+
ocranOutput.appendChild(span);
|
|
548
|
+
};
|
|
549
|
+
const ocranConsole = {};
|
|
550
|
+
const ocranStarted = () => document.getElementById("ocran-status")?.remove();
|
|
551
|
+
const ocranFailed = (error) => {
|
|
552
|
+
ocranStarted();
|
|
553
|
+
ocranAppend(String(error && error.message || error) + #{newline ? '""' : '"\\n"'}, "stderr");
|
|
554
|
+
ocranConsole.error(error);
|
|
555
|
+
};
|
|
556
|
+
for (const [name, stream] of [["log", null], ["info", null], ["warn", "stderr"], ["error", "stderr"]]) {
|
|
557
|
+
const original = ocranConsole[name] = console[name].bind(console);
|
|
558
|
+
console[name] = (...args) => {
|
|
559
|
+
original(...args);
|
|
560
|
+
ocranAppend(args.map(String).join(" "), stream);
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
</script>
|
|
564
|
+
#{runtime.gsub(/^/, " ").rstrip}
|
|
565
|
+
</body>
|
|
566
|
+
</html>
|
|
567
|
+
HTML
|
|
568
|
+
end
|
|
569
|
+
end
|
|
570
|
+
end
|