klenod-runtime 0.0.1

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: d2479157e269db0089e1ead92345eaf605cbd8e4138977af0950cc8003d42dac
4
+ data.tar.gz: 9b451904ee8f93cc7246098e7ec34991b8b1523239851a5be68f9650b99b7926
5
+ SHA512:
6
+ metadata.gz: 8c40bb3320f1f32a106484d44ffcd51379864f1fa2b0046dc75994c8573e8e754d8b253a9e9e5b3a044c109fe7f3563ad2e9965803e8e446b16bd79eb850c3cf
7
+ data.tar.gz: de76f09e4915a6939becd5a10a51a567e32eedfbe196bf1d9ea039ec6586fd1c88a57555a523c3241156d702045ff70b421cde4081020e8f790a5ac09cdf6a91
data/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # klenod-runtime
2
+
3
+ `klenod-runtime` loads and evaluates
4
+ [Klenod](https://github.com/aalin/klenod) bundles in production.
5
+
6
+ It contains only the runtime pieces needed after a bundle has already been built:
7
+
8
+ - `Klenod::Runtime::Bundle`
9
+ - `Klenod::Runtime::Mod`
10
+ - lazy import support
11
+ - source maps and backtrace rewriting
12
+
13
+ This gem should stay free of build plugins and heavyweight build-time dependencies such as RMagick, Syntax Tree, Haml, CSS processing, or file watching.
14
+
15
+ Use this gem in applications that load an existing bundle:
16
+
17
+ ```ruby
18
+ require "klenod/runtime"
19
+
20
+ bundle = Klenod::Runtime.load_bundle("dist/klenod.bundle")
21
+ exports = bundle.exports("pages/server")
22
+ ```
23
+
24
+ Production servers can explicitly preload bundled modules:
25
+
26
+ ```ruby
27
+ bundle.preload_entrypoints
28
+ ```
29
+
30
+ Build bundles with [klenod-build](https://github.com/aalin/klenod/tree/main/gems/klenod-build).
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright Andrés Alin <andreas.alin@gmail.com>
5
+ # License: AGPL-3.0
6
+
7
+ module Klenod
8
+ module Runtime
9
+ class BacktraceRewriter
10
+ class BacktraceString < String
11
+ attr_reader :parsed_backtrace_entry
12
+
13
+ def initialize(entry)
14
+ super(entry.to_s)
15
+ @parsed_backtrace_entry = entry
16
+ end
17
+ end
18
+
19
+ ParsedBacktraceEntry =
20
+ Data.define(:file, :line, :description) do
21
+ def self.parse(line)
22
+ case line
23
+ in BacktraceString
24
+ line.parsed_backtrace_entry
25
+ in /\A(?<file>.*):(?<line>\d+):in '(?<description>.*)'\z/
26
+ new($~[:file], $~[:line].to_i, $~[:description])
27
+ else
28
+ nil
29
+ end
30
+ end
31
+
32
+ def to_s
33
+ "#{file}:#{line}:in '#{description}'"
34
+ end
35
+
36
+ def to_backtrace_string
37
+ BacktraceString.new(self)
38
+ end
39
+ end
40
+
41
+ def initialize(mods)
42
+ @source_maps = source_maps_for(mods)
43
+ @source_map_cache = Hash.new { |h, path| h[path] = @source_maps[path] }
44
+ @constant_display_names = constant_display_names_for(mods)
45
+ end
46
+
47
+ def format_exception(e, source_path: nil)
48
+ reset = "\e[0;48;5;52m"
49
+ rewrite_exception(e)
50
+ sources = format_sources(e.backtrace)
51
+
52
+ [
53
+ "\e[1;31;47m ERROR \e[3;31;47m #{e.class.name}: #{e.message} #{reset}",
54
+ "\e[1;34mBacktrace:#{reset}",
55
+ e
56
+ .backtrace
57
+ .map do |line|
58
+ if line in BacktraceString
59
+ format(
60
+ "#{reset}\e[2mfrom #{reset}\e[1m%<file>s:%<line>d#{reset}\e[2m:in '#{reset}\e[1m%<description>s#{reset}\e[2m'#{reset}",
61
+ line.parsed_backtrace_entry.to_h
62
+ )
63
+ else
64
+ "from #{line}"
65
+ end
66
+ end
67
+ .join("\n"),
68
+ formatted_sources(sources, reset)
69
+ ].compact.join("\n") + "\e[0m"
70
+ end
71
+
72
+ def rewrite_exception(e)
73
+ rewrite_exception_message(e)
74
+ e.set_backtrace(rewrite_backtrace(e.backtrace))
75
+ end
76
+
77
+ def rewrite_backtrace(backtrace)
78
+ backtrace.map do |line|
79
+ if (entry = ParsedBacktraceEntry.parse(line))
80
+ rewrite_backtrace_entry(entry).to_backtrace_string
81
+ else
82
+ line
83
+ end
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def formatted_sources(sources, reset)
90
+ return nil if sources.empty?
91
+
92
+ [
93
+ "\e[1;34mSources:#{reset}",
94
+ sources
95
+ .map do |file, formatted_source|
96
+ "\e[1m#{file}\e[0m\n#{formatted_source}"
97
+ end
98
+ .join("\n")
99
+ ].join("\n")
100
+ end
101
+
102
+ def source_maps_for(mods)
103
+ mods.each_with_object({}) do |(key, mod), index|
104
+ next unless mod.respond_to?(:source_map)
105
+
106
+ source_map = mod.source_map
107
+ next unless source_map
108
+
109
+ index[key.to_s] = source_map
110
+ index[mod.path.to_s] = source_map if mod.respond_to?(:path)
111
+ index[mod.eval_path.to_s] = source_map if mod.respond_to?(:eval_path)
112
+ end
113
+ end
114
+
115
+ def rewrite_backtrace_entry(entry)
116
+ if (original_line_no = find_original_line_no(entry.file, entry.line))
117
+ entry.with(line: original_line_no, description: rewrite_const_paths(entry.description))
118
+ else
119
+ entry.with(description: rewrite_const_paths(entry.description))
120
+ end
121
+ end
122
+
123
+ def constant_display_names_for(mods)
124
+ mods.each_with_object({}) do |(key, mod), index|
125
+ next unless mod.respond_to?(:constant_name)
126
+
127
+ display_path = mod.respond_to?(:path) ? mod.path : key
128
+ index["Klenod::Runtime::Generated::#{mod.constant_name}"] = "Mod[#{display_path.inspect}]"
129
+ end
130
+ end
131
+
132
+ def rewrite_const_paths(value)
133
+ @constant_display_names.reduce(value.to_s) do |message, (generated_name, display_name)|
134
+ message.gsub(generated_name, display_name)
135
+ end
136
+ end
137
+
138
+ def rewrite_exception_message(error)
139
+ message = rewrite_const_paths(error.message)
140
+ return if message == error.message
141
+
142
+ error.define_singleton_method(:message) { message }
143
+ error.define_singleton_method(:to_s) { message }
144
+ end
145
+
146
+ def find_original_line_no(file, line_no)
147
+ @source_map_cache[file]&.find_original_line_no(line_no)
148
+ end
149
+
150
+ def format_sources(backtrace)
151
+ backtrace
152
+ .select { |line| line.is_a?(BacktraceString) }
153
+ .map(&:parsed_backtrace_entry)
154
+ .group_by(&:file)
155
+ .map do |file, entries|
156
+ if (source_map = @source_map_cache[file])
157
+ [file, format_source(source_map.input, entries.map(&:line))]
158
+ end
159
+ end
160
+ .compact
161
+ .to_h
162
+ end
163
+
164
+ def format_source(source, interesting_lines)
165
+ ranges =
166
+ merge_overlapping_ranges(interesting_lines.map { (it - 2)..(it + 2) })
167
+ lines = source.each_line.to_a
168
+
169
+ ranges
170
+ .map do |range|
171
+ range
172
+ .map do |i|
173
+ next if i <= 0
174
+ line = lines[i - 1]
175
+ next unless line
176
+
177
+ str = format("%3d: %s", i, line.chomp)
178
+ interesting_lines.include?(i) ? "\e[1;31m#{str}\e[0m" : str
179
+ end
180
+ .compact
181
+ .join("\n")
182
+ end
183
+ .join("\n\e[37;44m ... \e[0m\n")
184
+ end
185
+
186
+ def merge_overlapping_ranges(ranges)
187
+ ranges.each_with_object([]) do |range, merged|
188
+ if (idx = merged.find_index { |r| r.overlap?(range) })
189
+ overlapping = merged[idx]
190
+ merged[idx] = [overlapping.begin, range.begin].min..[
191
+ overlapping.end,
192
+ range.end
193
+ ].max
194
+ else
195
+ merged << range
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Klenod
4
+ module Runtime
5
+ ModuleSpec =
6
+ Data.define(:id, :source_path, :source, :imports, :source_map, :version, :constant_name)
7
+
8
+ ImportSpec = Data.define(:target_id, :value, :eager)
9
+ DefaultImport = Data.define(:name)
10
+
11
+ AssetSpec =
12
+ Data.define(:logical_name, :content_hash, :output_path, :content_type, :metadata)
13
+ AssetReference = Data.define(:index, :asset)
14
+
15
+ class Bundle
16
+ attr_reader :entrypoints, :modules, :assets, :source_root
17
+
18
+ def self.load(source, source_root: nil)
19
+ BundleFormat.load(source, source_root: source_root)
20
+ end
21
+
22
+ def self.load_file(path, source_root: nil)
23
+ load(path, source_root: source_root)
24
+ end
25
+
26
+ def initialize(entrypoints, modules, assets, source_root: nil)
27
+ @entrypoints = entrypoints
28
+ @modules = modules
29
+ @assets = assets
30
+ @source_root = source_root
31
+ @mods = {}
32
+ end
33
+
34
+ def load(entrypoint = nil)
35
+ id =
36
+ if entrypoint
37
+ entrypoint
38
+ elsif entrypoints.respond_to?(:values)
39
+ entrypoints.values.first
40
+ else
41
+ entrypoints.first
42
+ end
43
+
44
+ id = id.to_s
45
+ id = entrypoints.fetch(id, id) if entrypoints.respond_to?(:fetch)
46
+
47
+ instantiate(id)
48
+ end
49
+
50
+ def load_entrypoints
51
+ entrypoint_ids = entrypoints.respond_to?(:values) ? entrypoints.values : entrypoints
52
+ entrypoint_ids.map { |entrypoint| load(entrypoint) }
53
+ end
54
+
55
+ def preload(module_ref = nil)
56
+ module_ids =
57
+ if module_ref
58
+ reachable_module_ids(module_ref)
59
+ else
60
+ @modules.keys
61
+ end
62
+
63
+ module_ids.map { |module_id| instantiate(module_id) }
64
+ end
65
+
66
+ def preload_entrypoints
67
+ entrypoint_ids = entrypoints.respond_to?(:values) ? entrypoints.values : entrypoints
68
+ seen = Set.new
69
+ module_ids =
70
+ entrypoint_ids
71
+ .flat_map { |entrypoint| reachable_module_ids(entrypoint) }
72
+ .select { |module_id| seen.add?(module_id) }
73
+ module_ids.map { |module_id| instantiate(module_id) }
74
+ end
75
+
76
+ def exports(entrypoint = nil)
77
+ load(entrypoint).const_get(:Exports)
78
+ end
79
+
80
+ def mod(id)
81
+ @mods.fetch(id.to_s)
82
+ end
83
+
84
+ def asset(output_path)
85
+ @assets.fetch(output_path)
86
+ end
87
+
88
+ def assets_for(logical_name)
89
+ @assets.values.select { |asset| asset.logical_name == logical_name.to_s }
90
+ end
91
+
92
+ def assets_for_module(module_ref, type: nil, content_type: nil, recursive: true)
93
+ asset_references_for_module(module_ref, type: type, content_type: content_type, recursive: recursive)
94
+ .map(&:asset)
95
+ end
96
+
97
+ def asset_references_for_module(module_ref, type: nil, content_type: nil, recursive: true)
98
+ seen_assets = {}
99
+ module_ids_for_assets(module_ref, recursive: recursive)
100
+ .each_with_index
101
+ .flat_map do |module_id, index|
102
+ assets_for_runtime_module(module_id).filter_map do |asset|
103
+ next unless asset_matches?(asset, type: type, content_type: content_type)
104
+ next if seen_assets.key?(asset.output_path)
105
+
106
+ seen_assets[asset.output_path] = true
107
+ AssetReference.new(index:, asset:)
108
+ end
109
+ end
110
+ end
111
+
112
+ def each_asset(&block)
113
+ return enum_for(:each_asset) unless block
114
+
115
+ @assets.each_value(&block)
116
+ end
117
+
118
+ def source_root=(source_root)
119
+ @source_root = source_root&.to_s
120
+ @mods = {}
121
+ end
122
+
123
+ def marshal_dump
124
+ [@entrypoints, @modules, @assets, @source_root]
125
+ end
126
+
127
+ def marshal_load(data)
128
+ @entrypoints, @modules, @assets, @source_root = data
129
+ @mods = {}
130
+ end
131
+
132
+ def module_id_for(module_ref)
133
+ id = module_ref.respond_to?(:path) ? module_ref.path : module_ref.to_s
134
+ id = entrypoints.fetch(id, id) if entrypoints.respond_to?(:fetch)
135
+ return id if @modules.key?(id)
136
+ canonical_module_id = module_id_for_canonical_ref(id)
137
+ return canonical_module_id if canonical_module_id
138
+
139
+ if (relative_id = module_id_for_absolute_ref(id))
140
+ return relative_id
141
+ end
142
+
143
+ raise KeyError, "No module in bundle for #{module_ref.inspect}"
144
+ end
145
+
146
+ private
147
+
148
+ def assets_for_runtime_module(module_id)
149
+ module_spec = @modules.fetch(module_id)
150
+ logical_names = [module_spec.id, module_spec.source_path].uniq
151
+ @assets.values.select { |asset| logical_names.include?(asset.logical_name) }
152
+ end
153
+
154
+ SCHEME_PATTERN = /\A[A-Za-z][A-Za-z0-9+.-]*:/
155
+
156
+ def module_id_for_canonical_ref(id)
157
+ canonical =
158
+ if id.match?(SCHEME_PATTERN)
159
+ canonical_scheme_ref(id)
160
+ elsif !id.start_with?("/")
161
+ "app:/#{id.delete_prefix("/")}"
162
+ end
163
+
164
+ canonical if canonical && @modules.key?(canonical)
165
+ end
166
+
167
+ def canonical_scheme_ref(id)
168
+ scheme, rest = id.split(":", 2)
169
+ return id if rest.start_with?("/", "//")
170
+
171
+ "#{scheme}:/#{rest}"
172
+ end
173
+
174
+ def module_id_for_absolute_ref(id)
175
+ return nil unless source_root
176
+
177
+ root = File.expand_path(source_root)
178
+ path = File.expand_path(id)
179
+ return nil unless path.start_with?("#{root}/")
180
+
181
+ relative = path.delete_prefix("#{root}/")
182
+ relative if @modules.key?(relative)
183
+ end
184
+
185
+ def reachable_module_ids(module_ref)
186
+ root_id = module_id_for(module_ref)
187
+ seen = Set.new
188
+ ordered = []
189
+ queue = [root_id]
190
+ index = 0
191
+
192
+ while index < queue.length
193
+ module_id = queue[index]
194
+ index += 1
195
+ next unless seen.add?(module_id)
196
+ next unless @modules.key?(module_id)
197
+
198
+ ordered << module_id
199
+ queue.concat(
200
+ @modules
201
+ .fetch(module_id)
202
+ .imports
203
+ .values
204
+ .map { |import_spec| import_spec.is_a?(ImportSpec) ? import_spec.target_id : import_spec }
205
+ )
206
+ end
207
+
208
+ ordered
209
+ end
210
+
211
+ def module_ids_for_assets(module_ref, recursive:)
212
+ return Array(module_ref).map { |ref| module_id_for(ref) }.uniq unless recursive
213
+
214
+ seen = []
215
+ Array(module_ref).flat_map do |ref|
216
+ ordered_module_ids_for_assets(module_id_for(ref), seen)
217
+ end
218
+ end
219
+
220
+ def ordered_module_ids_for_assets(module_id, seen)
221
+ return [] if seen.include?(module_id)
222
+ return [] unless @modules.key?(module_id)
223
+
224
+ seen << module_id
225
+ dependency_ids =
226
+ @modules
227
+ .fetch(module_id)
228
+ .imports
229
+ .values
230
+ .map { |import_spec| import_spec.is_a?(ImportSpec) ? import_spec.target_id : import_spec }
231
+
232
+ if File.extname(module_id) == ".css"
233
+ dependency_ids.flat_map { |dependency_id| ordered_module_ids_for_assets(dependency_id, seen) } + [module_id]
234
+ else
235
+ [module_id] + dependency_ids.flat_map { |dependency_id| ordered_module_ids_for_assets(dependency_id, seen) }
236
+ end
237
+ end
238
+
239
+ def asset_matches?(asset, type:, content_type:)
240
+ return false if type && asset.metadata[:type] != type
241
+ return false if content_type && asset.content_type != content_type
242
+
243
+ true
244
+ end
245
+
246
+ def instantiate(id)
247
+ id = id.to_s
248
+ return @mods.fetch(id) if @mods.key?(id)
249
+
250
+ spec = @modules.fetch(id)
251
+ imports =
252
+ spec.imports.to_h do |dependency_id, target_id|
253
+ import_spec =
254
+ if target_id.is_a?(ImportSpec)
255
+ target_id
256
+ else
257
+ ImportSpec.new(target_id, nil, true)
258
+ end
259
+
260
+ value =
261
+ if import_spec.eager
262
+ resolve_import_value(import_spec)
263
+ else
264
+ LazyImport.new { resolve_import_value(import_spec) }
265
+ end
266
+ [dependency_id, value]
267
+ end
268
+
269
+ @mods[id] =
270
+ Mod.new(
271
+ spec.id,
272
+ spec.source,
273
+ imports: imports,
274
+ source_map: spec.source_map,
275
+ version: spec.version,
276
+ constant_name: spec.constant_name,
277
+ eval_path: eval_path_for(spec)
278
+ )
279
+ end
280
+
281
+ def eval_path_for(spec)
282
+ return spec.source_path unless source_root
283
+
284
+ File.join(source_root, spec.source_path)
285
+ end
286
+
287
+ def resolve_import_value(import_spec)
288
+ exports = instantiate(import_spec.target_id).const_get(:Exports)
289
+
290
+ case import_spec.value
291
+ when DefaultImport
292
+ exports.const_get(import_spec.value.name)
293
+ else
294
+ import_spec.value || exports
295
+ end
296
+ end
297
+ end
298
+ end
299
+ end
300
+
301
+ require_relative "bundle_format"
@@ -0,0 +1,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "version"
6
+ require_relative "source_map"
7
+
8
+ module Klenod
9
+ module Runtime
10
+ class BundleFormatError < StandardError
11
+ end
12
+
13
+ module BundleFormat
14
+ MAGIC = "MODPACK_BUNDLE_V1\n"
15
+ FORMAT_VERSION = 1
16
+
17
+ module_function
18
+
19
+ def dump(bundle)
20
+ MAGIC + JSON.generate(payload_for(bundle))
21
+ end
22
+
23
+ def load(source, source_root: nil)
24
+ load_bytes(read_source(source), source_root: source_root)
25
+ end
26
+
27
+ def load_bytes(bytes, source_root: nil)
28
+ raise BundleFormatError, "Invalid Klenod bundle header" unless bytes.start_with?(MAGIC)
29
+
30
+ body = bytes.byteslice(MAGIC.bytesize, bytes.bytesize - MAGIC.bytesize)
31
+ payload = JSON.parse(body)
32
+ bundle_from_payload(payload, source_root: source_root)
33
+ rescue JSON::ParserError => error
34
+ raise BundleFormatError, "Invalid Klenod bundle JSON: #{error.message}"
35
+ end
36
+
37
+ def payload_for(bundle)
38
+ {
39
+ "format_version" => FORMAT_VERSION,
40
+ "runtime_version" => Runtime::VERSION,
41
+ "source_root" => encode_value(bundle.source_root),
42
+ "entrypoints" => encode_value(bundle.entrypoints),
43
+ "modules" => encode_modules(bundle.modules),
44
+ "assets" => encode_assets(bundle.assets)
45
+ }
46
+ end
47
+
48
+ def bundle_from_payload(payload, source_root: nil)
49
+ validate_payload!(payload)
50
+
51
+ bundle =
52
+ Bundle.new(
53
+ decode_value(payload.fetch("entrypoints")),
54
+ decode_modules(payload.fetch("modules")),
55
+ decode_assets(payload.fetch("assets")),
56
+ source_root: decode_value(payload["source_root"])
57
+ )
58
+ bundle.source_root = source_root if source_root
59
+ bundle
60
+ end
61
+
62
+ def read_source(source)
63
+ source.respond_to?(:read) ? source.read : File.binread(source)
64
+ end
65
+
66
+ def validate_payload!(payload)
67
+ raise BundleFormatError, "Malformed Klenod bundle payload" unless payload.is_a?(Hash)
68
+
69
+ version = payload["format_version"]
70
+ unless version == FORMAT_VERSION
71
+ raise BundleFormatError, "Unsupported Klenod bundle format version: #{version.inspect}"
72
+ end
73
+
74
+ %w[runtime_version source_root entrypoints modules assets].each do |key|
75
+ raise BundleFormatError, "Malformed Klenod bundle payload: missing #{key}" unless payload.key?(key)
76
+ end
77
+ end
78
+
79
+ def encode_modules(modules)
80
+ modules.to_h do |id, spec|
81
+ [
82
+ id.to_s,
83
+ {
84
+ "id" => spec.id,
85
+ "source_path" => spec.source_path,
86
+ "source" => spec.source,
87
+ "imports" => encode_imports(spec.imports),
88
+ "source_map" => encode_source_map(spec.source_map),
89
+ "version" => spec.version,
90
+ "constant_name" => spec.constant_name
91
+ }
92
+ ]
93
+ end
94
+ end
95
+
96
+ def decode_modules(payload)
97
+ expect_hash!(payload, "modules").to_h do |id, spec_payload|
98
+ spec_payload = expect_hash!(spec_payload, "module #{id}")
99
+ [
100
+ id,
101
+ ModuleSpec.new(
102
+ spec_payload.fetch("id"),
103
+ spec_payload.fetch("source_path"),
104
+ spec_payload.fetch("source"),
105
+ decode_imports(spec_payload.fetch("imports")),
106
+ decode_source_map(spec_payload["source_map"], spec_payload.fetch("source")),
107
+ spec_payload.fetch("version"),
108
+ spec_payload.fetch("constant_name")
109
+ )
110
+ ]
111
+ end
112
+ end
113
+
114
+ def encode_imports(imports)
115
+ imports.to_h { |name, import| [name.to_s, encode_import(import)] }
116
+ end
117
+
118
+ def decode_imports(payload)
119
+ expect_hash!(payload, "imports").to_h { |name, import| [name, decode_import(import)] }
120
+ end
121
+
122
+ def encode_import(import)
123
+ case import
124
+ when ImportSpec
125
+ {
126
+ "type" => "import_spec",
127
+ "target_id" => import.target_id,
128
+ "value" => encode_value(import.value),
129
+ "eager" => import.eager
130
+ }
131
+ else
132
+ {
133
+ "type" => "module_id",
134
+ "target_id" => import.to_s
135
+ }
136
+ end
137
+ end
138
+
139
+ def decode_import(payload)
140
+ payload = expect_hash!(payload, "import")
141
+ case payload.fetch("type")
142
+ when "import_spec"
143
+ ImportSpec.new(payload.fetch("target_id"), decode_value(payload["value"]), payload.fetch("eager"))
144
+ when "module_id"
145
+ payload.fetch("target_id")
146
+ else
147
+ raise BundleFormatError, "Unknown import type: #{payload["type"].inspect}"
148
+ end
149
+ end
150
+
151
+ def encode_assets(assets)
152
+ assets.to_h do |output_path, asset|
153
+ [
154
+ output_path.to_s,
155
+ {
156
+ "logical_name" => asset.logical_name,
157
+ "content_hash" => asset.content_hash,
158
+ "output_path" => asset.output_path,
159
+ "content_type" => asset.content_type,
160
+ "metadata" => encode_value(asset.metadata)
161
+ }
162
+ ]
163
+ end
164
+ end
165
+
166
+ def decode_assets(payload)
167
+ expect_hash!(payload, "assets").to_h do |output_path, asset_payload|
168
+ asset_payload = expect_hash!(asset_payload, "asset #{output_path}")
169
+ [
170
+ output_path,
171
+ AssetSpec.new(
172
+ asset_payload.fetch("logical_name"),
173
+ asset_payload.fetch("content_hash"),
174
+ asset_payload.fetch("output_path"),
175
+ asset_payload.fetch("content_type"),
176
+ decode_value(asset_payload.fetch("metadata"))
177
+ )
178
+ ]
179
+ end
180
+ end
181
+
182
+ def encode_source_map(source_map)
183
+ return nil unless source_map
184
+
185
+ {
186
+ "input" => source_map.input,
187
+ "marks_by_output_line" =>
188
+ source_map.marks_by_output_line.to_h do |line, mark|
189
+ [line.to_s, mark.line]
190
+ end
191
+ }
192
+ end
193
+
194
+ def decode_source_map(payload, output)
195
+ return nil unless payload
196
+
197
+ payload = expect_hash!(payload, "source map")
198
+ marks =
199
+ expect_hash!(payload.fetch("marks_by_output_line"), "source map marks").to_h do |line, source_line|
200
+ [line.to_i, SourceMap::Mark.new(source_line)]
201
+ end
202
+ SourceMap::SourceMap.new(payload.fetch("input"), output, marks.freeze)
203
+ end
204
+
205
+ def encode_value(value)
206
+ case value
207
+ when nil, true, false, String, Integer, Float
208
+ value
209
+ when Symbol
210
+ {"__klenod_type" => "symbol", "value" => value.to_s}
211
+ when Array
212
+ value.map { |item| encode_value(item) }
213
+ when Hash
214
+ {
215
+ "__klenod_type" => "hash",
216
+ "entries" =>
217
+ value.map do |key, hash_value|
218
+ [encode_value(key), encode_value(hash_value)]
219
+ end
220
+ }
221
+ when DefaultImport
222
+ {"__klenod_type" => "default_import", "name" => value.name.to_s}
223
+ else
224
+ raise BundleFormatError, "Cannot encode bundle value: #{value.class}"
225
+ end
226
+ end
227
+
228
+ def decode_value(value)
229
+ case value
230
+ when nil, true, false, String, Integer, Float
231
+ value
232
+ when Array
233
+ value.map { |item| decode_value(item) }
234
+ when Hash
235
+ case value["__klenod_type"]
236
+ when "symbol"
237
+ value.fetch("value").to_sym
238
+ when "hash"
239
+ value.fetch("entries").to_h do |key, hash_value|
240
+ [decode_value(key), decode_value(hash_value)]
241
+ end
242
+ when "default_import"
243
+ DefaultImport.new(value.fetch("name").to_sym)
244
+ else
245
+ value.to_h { |key, hash_value| [key, decode_value(hash_value)] }
246
+ end
247
+ else
248
+ raise BundleFormatError, "Cannot decode bundle value: #{value.class}"
249
+ end
250
+ end
251
+
252
+ def expect_hash!(value, label)
253
+ return value if value.is_a?(Hash)
254
+
255
+ raise BundleFormatError, "Malformed Klenod bundle payload: #{label} must be an object"
256
+ end
257
+ end
258
+ end
259
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Klenod
6
+ module Runtime
7
+ module Generated
8
+ end
9
+
10
+ class LazyImport
11
+ def initialize(&loader)
12
+ @loader = loader
13
+ @loaded = false
14
+ @value = nil
15
+ end
16
+
17
+ def call
18
+ return @value if @loaded
19
+
20
+ @value = @loader.call
21
+ @loaded = true
22
+ @value
23
+ end
24
+
25
+ alias_method :value, :call
26
+
27
+ def loaded?
28
+ @loaded
29
+ end
30
+
31
+ def reset!
32
+ @loaded = false
33
+ @value = nil
34
+ self
35
+ end
36
+ end
37
+
38
+ class Mod < Module
39
+ class Exports < Module
40
+ def initialize(mod, imports)
41
+ @mod = mod
42
+ @imports = imports
43
+ end
44
+
45
+ def inspect
46
+ "#{@mod.inspect}::Exports"
47
+ end
48
+
49
+ def __klenod_import__(dependency_id)
50
+ @imports.fetch(dependency_id)
51
+ end
52
+
53
+ def __klenod_lazy_import__(dependency_id)
54
+ @imports.fetch(dependency_id)
55
+ end
56
+ end
57
+
58
+ attr_reader :path, :source, :source_map, :version, :constant_name, :eval_path
59
+
60
+ def self.constant_name_for(path)
61
+ "Mod_#{Digest::SHA256.hexdigest(path)[0, 24]}"
62
+ end
63
+
64
+ def inspect
65
+ "Mod(#{path.inspect})"
66
+ end
67
+
68
+ def initialize(path, source, imports: {}, source_map: nil, version: 0, constant_name: nil, eval_path: nil)
69
+ @path = path
70
+ @source = source
71
+ @imports = imports
72
+ @source_map = source_map
73
+ @version = version
74
+ @constant_name = constant_name || self.class.constant_name_for(path)
75
+ @eval_path = eval_path || path
76
+ register_constant
77
+ create_exports
78
+ end
79
+
80
+ def marshal_dump
81
+ [@path, @source, @source_map, @version, @constant_name, @eval_path]
82
+ end
83
+
84
+ def marshal_load(data)
85
+ @path, @source, @source_map, @version, @constant_name, @eval_path = data
86
+ @eval_path ||= @path
87
+ @imports = {}
88
+ register_constant
89
+ create_exports
90
+ end
91
+
92
+ def to_s
93
+ "#<#{self.class.name} path=#{@path.inspect}>"
94
+ end
95
+
96
+ private
97
+
98
+ def register_constant
99
+ Generated.__send__(:remove_const, @constant_name) if Generated.const_defined?(@constant_name, false)
100
+ Generated.const_set(@constant_name, self)
101
+ end
102
+
103
+ def create_exports
104
+ remove_const(:Exports) if const_defined?(:Exports, false)
105
+
106
+ exports = Exports.new(self, @imports)
107
+ exports.module_eval(@source, @eval_path, 1)
108
+ const_set(:Exports, exports)
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Klenod
4
+ module Runtime
5
+ module SourceMap
6
+ MARK_PREFIX = "SourceMapMark"
7
+
8
+ Mark = Data.define(:line) do
9
+ def self.parse(value)
10
+ return value if value.is_a?(self)
11
+ return nil unless value
12
+
13
+ if value =~ /\A#{MARK_PREFIX}:(?<line>\d+):?\z/
14
+ new($~[:line].to_i)
15
+ end
16
+ end
17
+
18
+ def to_s
19
+ "#{MARK_PREFIX}:#{line}"
20
+ end
21
+ end
22
+
23
+ SourceMap = Data.define(:input, :output, :marks_by_output_line) do
24
+ def self.parse(input, output)
25
+ marks = {}
26
+ return new(input, output, marks.freeze) unless output.include?(MARK_PREFIX)
27
+
28
+ output.each_line.with_index(1) do |line, line_no|
29
+ if (mark = parse_line_mark(line))
30
+ marks[line_no] = mark
31
+ end
32
+ end
33
+
34
+ new(input, output, marks.freeze)
35
+ end
36
+
37
+ def self.parse_line_mark(line)
38
+ start = line.index(MARK_PREFIX)
39
+ return nil unless start
40
+
41
+ index = start + MARK_PREFIX.length
42
+ return nil unless line.getbyte(index) == 58 # :
43
+
44
+ index += 1
45
+ line_start = index
46
+ index += 1 while (byte = line.getbyte(index)) && byte >= 48 && byte <= 57
47
+ return nil if index == line_start
48
+
49
+ Mark.new(line.byteslice(line_start, index - line_start).to_i)
50
+ end
51
+
52
+ def find_original_line_no(output_line_no)
53
+ # Marks apply until the next mark, so generated code only needs a mark
54
+ # before the expression it came from.
55
+ marks_by_output_line
56
+ .select { |line_no, _mark| line_no <= output_line_no }
57
+ .max_by { |line_no, _mark| line_no }
58
+ &.last
59
+ &.line
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Klenod
4
+ module Runtime
5
+ VERSION = "0.0.1"
6
+ end
7
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "runtime/version"
4
+ require_relative "runtime/source_map"
5
+ require_relative "runtime/backtrace_rewriter"
6
+ require_relative "runtime/mod"
7
+ require_relative "runtime/bundle"
8
+ require_relative "runtime/bundle_format"
9
+
10
+ module Klenod
11
+ module Runtime
12
+ EXECUTABLE_BUNDLE_MARKER = "\n__END__\n".b.freeze
13
+
14
+ def self.load_bundle(source, source_root: nil)
15
+ Bundle.load(source, source_root: source_root)
16
+ end
17
+
18
+ def self.load_bundle_in_box(source, source_root: nil, box: nil)
19
+ unless defined?(Ruby::Box) && Ruby::Box.enabled?
20
+ raise "Ruby::Box is disabled. Set RUBY_BOX=1 environment variable to use Ruby::Box."
21
+ end
22
+
23
+ bytes = source.respond_to?(:read) ? source.read : File.binread(source)
24
+ box = prepare_box(box)
25
+ box::Klenod::Runtime::BundleFormat.load_bytes(bytes, source_root: source_root)
26
+ end
27
+
28
+ def self.prepare_box(box = nil)
29
+ unless defined?(Ruby::Box) && Ruby::Box.enabled?
30
+ raise "Ruby::Box is disabled. Set RUBY_BOX=1 environment variable to use Ruby::Box."
31
+ end
32
+
33
+ box ||= Ruby::Box.new
34
+ box.require(File.expand_path(__FILE__)) unless runtime_loaded_in_box?(box)
35
+ box
36
+ end
37
+
38
+ def self.runtime_loaded_in_box?(box)
39
+ box.const_defined?(:Klenod, false) &&
40
+ box::Klenod.const_defined?(:Runtime, false)
41
+ end
42
+
43
+ def self.load_executable_bundle(path, source_root: nil)
44
+ bytes = File.binread(path)
45
+ marker_index = bytes.index(EXECUTABLE_BUNDLE_MARKER)
46
+ raise ArgumentError, "Missing __END__ marker in executable bundle: #{path}" unless marker_index
47
+
48
+ payload = bytes.byteslice(marker_index + EXECUTABLE_BUNDLE_MARKER.bytesize, bytes.bytesize)
49
+ BundleFormat.load_bytes(payload, source_root: source_root)
50
+ end
51
+ end
52
+ end
metadata ADDED
@@ -0,0 +1,51 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: klenod-runtime
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Andrés Alin
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Klenod runtime loads serialized Ruby module bundles without build plugins
13
+ or development dependencies.
14
+ email:
15
+ - andreas.alin@gmail.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - README.md
21
+ - lib/klenod/runtime.rb
22
+ - lib/klenod/runtime/backtrace_rewriter.rb
23
+ - lib/klenod/runtime/bundle.rb
24
+ - lib/klenod/runtime/bundle_format.rb
25
+ - lib/klenod/runtime/mod.rb
26
+ - lib/klenod/runtime/source_map.rb
27
+ - lib/klenod/runtime/version.rb
28
+ homepage: https://github.com/aalin/klenod
29
+ licenses:
30
+ - MIT
31
+ metadata:
32
+ homepage_uri: https://github.com/aalin/klenod
33
+ source_code_uri: https://github.com/aalin/klenod/tree/main/gems/klenod-runtime
34
+ rdoc_options: []
35
+ require_paths:
36
+ - lib
37
+ required_ruby_version: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: 4.0.6
42
+ required_rubygems_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ requirements: []
48
+ rubygems_version: 4.0.16
49
+ specification_version: 4
50
+ summary: Runtime loader for Klenod bundles.
51
+ test_files: []