proscenium 0.26.0-x86_64-linux-gnu

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.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/CODE_OF_CONDUCT.md +84 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +1020 -0
  5. data/exe/proscenium +19 -0
  6. data/exe/psm +5 -0
  7. data/lib/generators/proscenium/bun/bun_generator.rb +150 -0
  8. data/lib/generators/proscenium/bun/templates/proscenium.preload.js +61 -0
  9. data/lib/proscenium/app_package.rb +90 -0
  10. data/lib/proscenium/builder.rb +403 -0
  11. data/lib/proscenium/bundled_gems.rb +140 -0
  12. data/lib/proscenium/cli/brackets.rb +66 -0
  13. data/lib/proscenium/cli/bun_linker.rb +79 -0
  14. data/lib/proscenium/cli/capabilities.json +29 -0
  15. data/lib/proscenium/cli/collisions.rb +94 -0
  16. data/lib/proscenium/cli/contexts.rb +133 -0
  17. data/lib/proscenium/cli/diff.rb +80 -0
  18. data/lib/proscenium/cli/error.rb +291 -0
  19. data/lib/proscenium/cli/gem_archive.rb +44 -0
  20. data/lib/proscenium/cli/gem_check.rb +150 -0
  21. data/lib/proscenium/cli/gitignore.rb +63 -0
  22. data/lib/proscenium/cli/inspect.rb +257 -0
  23. data/lib/proscenium/cli/install.rb +378 -0
  24. data/lib/proscenium/cli/manager.rb +323 -0
  25. data/lib/proscenium/cli/platform_variants.rb +82 -0
  26. data/lib/proscenium/cli/pnpm_approvals.rb +109 -0
  27. data/lib/proscenium/cli/pnpm_workspace.rb +149 -0
  28. data/lib/proscenium/cli/project_lock.rb +106 -0
  29. data/lib/proscenium/cli/registration.rb +140 -0
  30. data/lib/proscenium/cli/reporter.rb +103 -0
  31. data/lib/proscenium/cli/rules.rb +169 -0
  32. data/lib/proscenium/cli/runner.rb +142 -0
  33. data/lib/proscenium/cli/verify.rb +107 -0
  34. data/lib/proscenium/cli.rb +122 -0
  35. data/lib/proscenium/context_map.rb +181 -0
  36. data/lib/proscenium/css_module/path.rb +32 -0
  37. data/lib/proscenium/css_module/transformer.rb +118 -0
  38. data/lib/proscenium/css_module.rb +63 -0
  39. data/lib/proscenium/dependency_context.rb +74 -0
  40. data/lib/proscenium/ensure_loaded.rb +31 -0
  41. data/lib/proscenium/error.rb +10 -0
  42. data/lib/proscenium/ext/proscenium +0 -0
  43. data/lib/proscenium/ext/proscenium.h +113 -0
  44. data/lib/proscenium/helper.rb +82 -0
  45. data/lib/proscenium/importer.rb +189 -0
  46. data/lib/proscenium/log_subscriber.rb +38 -0
  47. data/lib/proscenium/manifest.rb +48 -0
  48. data/lib/proscenium/mapping_generation.rb +64 -0
  49. data/lib/proscenium/middleware/base.rb +102 -0
  50. data/lib/proscenium/middleware/chunks.rb +46 -0
  51. data/lib/proscenium/middleware/esbuild.rb +11 -0
  52. data/lib/proscenium/middleware/ruby_gems.rb +37 -0
  53. data/lib/proscenium/middleware/silence_request.rb +31 -0
  54. data/lib/proscenium/middleware/vendor.rb +31 -0
  55. data/lib/proscenium/middleware.rb +67 -0
  56. data/lib/proscenium/monkey.rb +77 -0
  57. data/lib/proscenium/railtie.rb +106 -0
  58. data/lib/proscenium/railties/assets.rake +19 -0
  59. data/lib/proscenium/resolver.rb +67 -0
  60. data/lib/proscenium/runtime/bootstrap.js +266 -0
  61. data/lib/proscenium/runtime/bun.js +143 -0
  62. data/lib/proscenium/runtime/server.rb +702 -0
  63. data/lib/proscenium/side_load.rb +210 -0
  64. data/lib/proscenium/source_path.rb +15 -0
  65. data/lib/proscenium/stale_contexts.rb +118 -0
  66. data/lib/proscenium/templates/rescues/build_error.html.erb +32 -0
  67. data/lib/proscenium/utils.rb +104 -0
  68. data/lib/proscenium/version.rb +5 -0
  69. data/lib/proscenium/yaml_tree.rb +48 -0
  70. data/lib/proscenium.rb +87 -0
  71. metadata +148 -0
@@ -0,0 +1,403 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ffi'
4
+ require 'pathname'
5
+ require_relative 'error'
6
+
7
+ module Proscenium
8
+ class Builder
9
+ ENVIRONMENTS = { development: 1, test: 2, production: 3 }.freeze
10
+
11
+ class Result < FFI::Struct
12
+ layout :success, :bool,
13
+ :response, :pointer,
14
+ :content_hash, :pointer
15
+ end
16
+
17
+ class ResolveResult < FFI::Struct
18
+ layout :success, :bool,
19
+ :url_path, :pointer,
20
+ :abs_path, :pointer
21
+ end
22
+
23
+ class CompileResult < FFI::Struct
24
+ layout :success, :bool,
25
+ :messages, :pointer
26
+ end
27
+
28
+ # The compiled Go library. It ships in the platform gems only, gated in the gemspec, so a
29
+ # host that matches no platform gem installs the platform-less gem and finds nothing here.
30
+ #
31
+ # Named explicitly rather than left to the loader: LoadLibrary appends `.dll` to a bare module
32
+ # name, but the documentation does not promise it for a full path, and being wrong here means
33
+ # every call fails at load with an error about a missing file rather than anything useful.
34
+ LIBRARY_NAME = Gem.win_platform? ? 'proscenium.dll' : 'proscenium'
35
+ LIBRARY_PATH = Pathname.new(__dir__).join('ext', LIBRARY_NAME)
36
+
37
+ # Raised in place of letting `ffi_lib` fail on its own. FFI reports a missing library as
38
+ # `Could not open library <path>` followed by the system search paths, which reads like a
39
+ # broken install and says nothing about platforms - so the one thing the reader needs to know,
40
+ # that Proscenium ships no binary for their platform, is the one thing it leaves out.
41
+ class UnsupportedPlatform < Error
42
+ def initialize(path)
43
+ super(<<~MESSAGE)
44
+ Proscenium has no compiled library for this platform (#{Gem::Platform.local}).
45
+
46
+ Proscenium is a Ruby gem wrapped around a Go library, so it ships a separate gem per
47
+ platform. Yours matched none of them, so you have the platform-less gem, which carries
48
+ no library at all. Nothing is corrupted - this platform is simply not built for.
49
+
50
+ Expected the library at: #{path}
51
+
52
+ The supported platforms are listed in the README. If yours should be among them, please
53
+ open an issue at https://github.com/joelmoss/proscenium/issues.
54
+ MESSAGE
55
+ end
56
+ end
57
+
58
+ module Request
59
+ extend FFI::Library
60
+
61
+ raise UnsupportedPlatform, LIBRARY_PATH unless LIBRARY_PATH.exist?
62
+
63
+ ffi_lib LIBRARY_PATH.to_s
64
+
65
+ # Windows unmaps the library at interpreter exit, and the Go runtime does not survive it.
66
+ #
67
+ # ffi's library_free calls dl_close on every platform but macOS, which is FreeLibrary here,
68
+ # and Ruby runs every T_DATA free during VM teardown. Go's c-shared runtime cannot be
69
+ # unloaded: its threads are still running when the mapping goes. On Linux this never bites,
70
+ # because the Go linker marks the library nodelete and dlclose is a no-op; Windows has no
71
+ # equivalent. Measured on windows-latest: three of five plain `require` runs exited 139,
72
+ # while five of five exited 0 when the script ended in `exit!`, which skips the teardown.
73
+ #
74
+ # Pinning the module means the reference count never reaches zero, so FreeLibrary returns
75
+ # without unmapping anything. Nothing here needs the handle, only the pin.
76
+ if Gem.win_platform?
77
+ module Kernel32
78
+ extend FFI::Library
79
+
80
+ ffi_lib 'kernel32'
81
+ attach_function :GetModuleHandleExW, %i[uint32 buffer_in pointer], :int
82
+ end
83
+
84
+ # GET_MODULE_HANDLE_EX_FLAG_PIN. Backslashes, because GetModuleHandle documents that a full
85
+ # path must use them, and LIBRARY_PATH is built from __dir__, which uses "/". Checked,
86
+ # because a pin that quietly failed would bring back an exit crash that only happens some
87
+ # of the time, with nothing to say why.
88
+ name = "#{LIBRARY_PATH.to_s.tr('/', '\\')}\0".encode(Encoding::UTF_16LE)
89
+ if Kernel32.GetModuleHandleExW(0x1, name, FFI::MemoryPointer.new(:pointer)).zero?
90
+ warn "Proscenium could not pin #{LIBRARY_PATH} (Windows error " \
91
+ "#{FFI::LastError.winapi_error}), so Ruby may crash when it exits."
92
+ end
93
+ end
94
+
95
+ enum :environment, [:development, 1, :test, :production]
96
+
97
+ # `blocking: true` releases the GVL for the duration of the call, so a build/resolve
98
+ # doesn't stall unrelated Ruby threads (eg. other requests in a multi-threaded server).
99
+ # Safe to do because the Go side shares nothing between calls: each call parses its own
100
+ # config, and the caches that outlive a call (i18n, svg, npm replacements) carry their own
101
+ # locks. See the note in main.go. There is no serialising mutex any more; a new piece of
102
+ # global state on the Go side needs its own.
103
+
104
+ attach_function :build_to_string, [
105
+ :string, # Path or entry point.
106
+ :pointer # Config as JSON.
107
+ ], Result.by_value, blocking: true
108
+
109
+ attach_function :resolve, [
110
+ :string, # path or entry point
111
+ :pointer # Config as JSON.
112
+ ], ResolveResult.by_value, blocking: true
113
+
114
+ attach_function :compile, [
115
+ :pointer # Config as JSON.
116
+ ], CompileResult.by_value, blocking: true
117
+
118
+ attach_function :reset_config, [], :void, blocking: true
119
+
120
+ attach_function :free_cstr, [:pointer], :void
121
+ end
122
+
123
+ class BuildError < Error
124
+ attr_reader :error, :path
125
+
126
+ # One esbuild message as text: its text, detail and location on one line, then each note
127
+ # with its own location on a line of its own. A note is where a recovered Go panic carries
128
+ # its stack, and where a failure inside a nested build names the file that imported it -
129
+ # without this they never left the JSON.
130
+ def self.format_message(message)
131
+ msg = message['Text'].to_s.dup
132
+ msg << ' - ' << message['Detail'] if message['Detail'].is_a?(String)
133
+ msg << format_location(message['Location'])
134
+ Array(message['Notes']).each do |note|
135
+ msg << "\n" << note['Text'].to_s << format_location(note['Location'])
136
+ end
137
+
138
+ msg
139
+ end
140
+
141
+ def self.format_location(location)
142
+ location ? " at #{location['File']}:#{location['Line']}:#{location['Column']}" : ''
143
+ end
144
+
145
+ # Go always answers with a JSON object. Anything else - a nil pointer, a bare scalar, text -
146
+ # is a contract violation, and is shown as the message rather than replaced by a parse error
147
+ # that hides it.
148
+ def self.parse_json(json)
149
+ parsed = JSON.parse(json)
150
+ parsed if parsed.is_a?(Hash)
151
+ rescue JSON::ParserError, TypeError
152
+ nil
153
+ end
154
+
155
+ def initialize(path, error)
156
+ @path = path
157
+ @error = self.class.parse_json(error) || { 'Text' => error.to_s }
158
+
159
+ super("Failed to build #{path} - #{self.class.format_message(@error)}")
160
+ end
161
+ end
162
+
163
+ # Raised by `compile` with every error esbuild reported. It used to return `false` and throw
164
+ # the messages away, so a failed precompile gave no reason at all.
165
+ class CompileError < Error
166
+ attr_reader :messages
167
+
168
+ def initialize(messages)
169
+ @messages = BuildError.parse_json(messages) || { 'Errors' => [{ 'Text' => messages.to_s }] }
170
+
171
+ errors = Array(@messages['Errors']).map { |message| BuildError.format_message(message) }
172
+
173
+ super("Failed to compile assets - #{errors.join("\n")}")
174
+ end
175
+ end
176
+
177
+ class ResolveError < Error
178
+ attr_reader :path
179
+
180
+ def initialize(path, msg)
181
+ @path = path
182
+ super("Failed to resolve #{path} - #{msg}")
183
+ end
184
+ end
185
+
186
+ def self.build_to_string(path, root: nil, **overrides)
187
+ new(root:, **overrides).build_to_string(path)
188
+ end
189
+
190
+ def self.resolve(path, root: nil, **overrides)
191
+ new(root:, **overrides).resolve(path)
192
+ end
193
+
194
+ def self.compile(root: nil, **overrides)
195
+ new(root:, **overrides).compile
196
+ end
197
+
198
+ # Why no build may run now, or nil (#154): an install is under way or stopped before it
199
+ # finished, or the committed gem dependency contexts are stale. With
200
+ # PROSCENIUM_STALE_CONTEXT=warn, for an incident, stale contexts are logged once instead.
201
+ def self.refusal(root = Rails.root)
202
+ project = ContextMap.project_root
203
+ return ContextMap::INSTALLING_MESSAGE if ContextMap.installing?(project)
204
+
205
+ outside = outside_rails_root(project, root) and return outside
206
+
207
+ stale = StaleContexts.message(project, generation) or return
208
+ return stale unless ENV['PROSCENIUM_STALE_CONTEXT'] == 'warn'
209
+
210
+ @warned ||= Set.new
211
+ Rails.logger.warn("[Proscenium] #{stale}") if @warned.add?(stale)
212
+ nil
213
+ end
214
+
215
+ # Why an adopted app's contexts cannot be served from `root`, the root builds run in (the Rails
216
+ # app's), or nil (#154). They live at the bundle's root, beside the Gemfile; one outside `root`
217
+ # has no URL the engine serves, so such a layout is refused plainly, not left to fail
218
+ # mid-page. Only compares paths unless they differ.
219
+ def self.outside_rails_root(project, root = Rails.root)
220
+ return if within?(project, root) || !ContextMap.adopted?(project)
221
+
222
+ "Gem dependency contexts are under #{project}, outside the Rails app at #{root}, " \
223
+ 'which Proscenium cannot serve yet. Keep the Gemfile in the Rails root to use them.'
224
+ end
225
+
226
+ # Whether `path` is `root` or under it, as spelled or, through a link such as a Capistrano
227
+ # `current`, as resolved.
228
+ def self.within?(path, root)
229
+ under?(path, root) || under?(File.realpath(path), File.realpath(root))
230
+ rescue SystemCallError
231
+ false
232
+ end
233
+
234
+ def self.under?(path, root) = "#{path}/".start_with?("#{root}/")
235
+ private_class_method :within?, :under?
236
+
237
+ # The mapping generation builds use now (#154). See Resolver.generation.
238
+ def self.generation = Resolver.generation
239
+
240
+ # Resets nothing any more - Go keeps no config between calls. Kept because it is the one call
241
+ # into Go that needs no Rails app, which is how bin/verify-installed-gem and the packaging test
242
+ # prove the library loads, and benchmarks/bridge.rb times it as the bare cost of an FFI call.
243
+ def self.reset_config!
244
+ Request.reset_config
245
+ end
246
+
247
+ # `overrides` are merged over the config derived from `Proscenium.config`, and are passed
248
+ # straight through to the Go side. Intended for callers that are not serving a browser request
249
+ # and so want different build settings - `Bundle: false`, `Write: false`, `CodeSplitting: false`
250
+ # - than the app's own configuration. Keys must match `types.ConfigT`; Go silently ignores
251
+ # any it does not know.
252
+ def initialize(root: nil, **overrides)
253
+ @root = root
254
+ @overrides = overrides
255
+ @generation = self.class.generation
256
+ @request_config = configure(@generation)
257
+ end
258
+
259
+ # The config pointer for the current mapping generation: a Builder kept past an install, or
260
+ # made just before one finished, sends Go the context map builds now use, not the one it was
261
+ # made with (#154).
262
+ def request_config
263
+ generation = self.class.generation
264
+ @request_config = configure(@generation = generation) if generation != @generation
265
+ @request_config
266
+ end
267
+ private :request_config
268
+
269
+ def configure(generation)
270
+ config_hash = {
271
+ RootPath: (@root || Rails.root).to_s,
272
+ OutputDir: "public#{Proscenium.config.output_dir}",
273
+ GemPath: gem_root,
274
+ Environment: ENVIRONMENTS.fetch(Rails.env.to_sym, 2),
275
+ EnvVars: env_vars,
276
+ CodeSplitting: Proscenium.config.code_splitting,
277
+ RubyGems: Proscenium::BundledGems.paths,
278
+ Bundle: Proscenium.config.bundle,
279
+ Aliases: Proscenium.config.aliases,
280
+ External: Proscenium.config.external,
281
+ Precompile: Proscenium.config.precompile,
282
+ Debug: Proscenium.config.debug,
283
+ **ContextMap.config(ContextMap.project_root, generation)
284
+ }.merge(@overrides)
285
+
286
+ self.class.request_config_pointer(config_hash)
287
+ end
288
+ private :configure
289
+
290
+ class << self
291
+ # Building the config JSON and copying it into an FFI::MemoryPointer is the only real cost
292
+ # in instantiating a Builder (everything else is memoized attribute reads). Since the
293
+ # config is identical across the vast majority of calls (same root, same Rails env, same
294
+ # Proscenium.config), skip re-serializing and re-allocating it when nothing has changed.
295
+ #
296
+ # Callers keep their own reference to the returned pointer, so a later call replacing the
297
+ # memo does not invalidate a pointer already in use.
298
+ #
299
+ # The hash and its pointer are stored as one frozen pair in one ivar, and assigned once. Two
300
+ # ivars would need a lock: a reader could see a hash already updated while its pointer still
301
+ # pointed at the previous config, and get a build made with someone else's settings. One
302
+ # assignment cannot be observed half-done, so a reader sees either the old pair or the new
303
+ # one - never a mix - and there is no window for a test to have to reproduce.
304
+ def request_config_pointer(config_hash)
305
+ memo = @config_memo
306
+ return memo[1] if memo && memo[0] == config_hash
307
+
308
+ pointer = FFI::MemoryPointer.from_string(config_hash.to_json)
309
+ @config_memo = [config_hash, pointer].freeze
310
+
311
+ pointer
312
+ end
313
+ end
314
+
315
+ def build_to_string(path)
316
+ if (reason = self.class.refusal(@root || Rails.root))
317
+ raise BuildError.new(path, reason)
318
+ end
319
+
320
+ ActiveSupport::Notifications.instrument('build.proscenium', identifier: path) do
321
+ raw = Request.build_to_string(path, request_config)
322
+ result = { success: raw[:success], response: read_and_free(raw[:response]),
323
+ content_hash: read_and_free(raw[:content_hash]) }
324
+
325
+ raise BuildError.new(path, result[:response]) unless result[:success]
326
+
327
+ result
328
+ end
329
+ end
330
+
331
+ def resolve(path)
332
+ if (reason = self.class.refusal(@root || Rails.root))
333
+ raise ResolveError.new(path, reason)
334
+ end
335
+
336
+ ActiveSupport::Notifications.instrument('resolve.proscenium', identifier: path) do
337
+ raw = Request.resolve(path, request_config)
338
+ success = raw[:success]
339
+ url_path = read_and_free(raw[:url_path])
340
+ abs_path = read_and_free(raw[:abs_path])
341
+
342
+ raise ResolveError.new(path, url_path) unless success
343
+
344
+ [url_path, abs_path]
345
+ end
346
+ end
347
+
348
+ # Returns true, or raises CompileError with esbuild's messages.
349
+ def compile
350
+ if (reason = self.class.refusal(@root || Rails.root))
351
+ raise CompileError, reason
352
+ end
353
+
354
+ raw = Request.compile(request_config)
355
+ messages = read_and_free(raw[:messages])
356
+
357
+ raise CompileError, messages unless raw[:success]
358
+
359
+ raw[:success]
360
+ end
361
+
362
+ private
363
+
364
+ # The Go side allocates each of these strings with C.CString, which the Go runtime cannot
365
+ # see or collect - it must be freed from this side once we're done reading it.
366
+ #
367
+ # `read_string` always returns ASCII-8BIT, but everything Go sends is UTF-8: paths, esbuild's
368
+ # output and its messages. Left binary, a non-ASCII character is several "characters" to
369
+ # anything working on the string, and JSON.generate warns (a later json will raise).
370
+ def read_and_free(ptr)
371
+ return nil if ptr.null?
372
+
373
+ ptr.read_string.force_encoding(Encoding::UTF_8)
374
+ ensure
375
+ Request.free_cstr(ptr)
376
+ end
377
+
378
+ # Build the ENV variables as determined by `Proscenium.config.env_vars` and
379
+ # `Proscenium::DEFAULT_ENV_VARS` to pass to esbuild.
380
+ #
381
+ # Each name becomes the esbuild define `proscenium.env.<NAME>`, so one that is not a JS
382
+ # identifier would fail every build, or with a dot define a nested path. Checked whether or not
383
+ # the variable is set, so a bad name fails the first build everywhere, not only where it is set.
384
+ #
385
+ # Only the ASCII rules are checked, on the raw bytes: Ruby's Unicode tables differ from
386
+ # esbuild's by Ruby version, so non-ASCII characters are left for esbuild to judge, and the
387
+ # bytes cannot raise an encoding error. An empty name passes, and `ENV.slice` drops it.
388
+ def env_vars
389
+ names = Array(Proscenium.config.env_vars).map(&:to_s)
390
+ if (invalid = names.select { |name| name.b.match?(/\A\d|[^\w$\x80-\xff]/n) }).any?
391
+ raise ArgumentError, 'config.proscenium.env_vars must be JavaScript identifiers, but got ' \
392
+ "#{invalid.map(&:inspect).join(', ')}"
393
+ end
394
+
395
+ ENV['NODE_ENV'] = ENV.fetch('RAILS_ENV', nil)
396
+ ENV.slice(*Proscenium::DEFAULT_ENV_VARS, *names)
397
+ end
398
+
399
+ def gem_root
400
+ Pathname.new(__dir__).join('..', '..').to_s
401
+ end
402
+ end
403
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'error'
5
+
6
+ module Proscenium
7
+ # The one reader of the bundle, for the engine and the `proscenium` CLI alike, so the two never
8
+ # disagree about which gems exist or which take part in JavaScript dependency installation
9
+ # (#154). It must not load ActiveSupport or anything else the CLI may not.
10
+ module BundledGems
11
+ # The app's package.json, or its `proscenium` key, is unreadable or not in the documented
12
+ # shape.
13
+ class ConfigError < Proscenium::Error; end
14
+
15
+ module_function
16
+
17
+ def paths
18
+ @paths ||= begin
19
+ specs = Bundler.load.specs.reject { |s| s.name == 'bundler' }.sort_by(&:name)
20
+
21
+ raise 'No gems in your Gemfile' if specs.empty?
22
+
23
+ bundle = {}
24
+ specs.each do |s|
25
+ bundle[s.name] = if s.name == 'proscenium'
26
+ Pathname(s.full_gem_path).join('lib/proscenium').to_s
27
+ else
28
+ s.full_gem_path
29
+ end
30
+ end
31
+ bundle
32
+ end
33
+ end
34
+
35
+ # The `@rubygems/` form of an absolute path inside a bundled gem, or nil when it is in none.
36
+ # A plain prefix match: a gem path is text, not a pattern, and may hold `+` or `(`.
37
+ #
38
+ # The longest root wins, which is Go's rule in `GemFromFsPath`, so a file under a gem nested in
39
+ # another gem's tree gets one URL from both sides. `max_by` keeps the first of equal roots, and
40
+ # `paths` is sorted by name, so a tie goes to the first name, as it does in Go.
41
+ def virtual_path(abs_path)
42
+ name, root = paths.select { |_, v| abs_path.start_with? "#{v}/" }.max_by { |_, v| v.length }
43
+ name && "@rubygems/#{name}#{abs_path.delete_prefix(root)}"
44
+ end
45
+
46
+ # Every installed gem but Bundler, sorted by name: the specs of the groups Bundler loaded.
47
+ def installed_specs = Bundler.load.specs.reject { it.name == 'bundler' }.sort_by(&:name)
48
+
49
+ # The names in Gemfile.lock, installed or not, or nil without a lockfile. A gem in a group
50
+ # BUNDLE_WITHOUT excludes, or locked for another platform, is here but not installed.
51
+ def locked_names
52
+ locked = Bundler.locked_gems
53
+ locked && locked.specs.map(&:name).uniq.sort
54
+ end
55
+
56
+ # Locked gems that are not installed: their committed contexts are trusted as they are (the
57
+ # production rule).
58
+ def excluded_names(specs = installed_specs)
59
+ (locked_names || []) - specs.map(&:name)
60
+ end
61
+
62
+ # `proscenium.gemOverrides` from the app's package.json: gem name => true or false, or {}
63
+ # without one.
64
+ def overrides(root)
65
+ path = File.join(root, 'package.json')
66
+ return {} unless File.exist?(path)
67
+
68
+ package = JSON.parse(File.read(path, encoding: 'bom|utf-8'))
69
+ raise ConfigError, 'package.json does not hold a JSON object' unless package.is_a?(Hash)
70
+
71
+ config = package['proscenium'] || {}
72
+ raise ConfigError, '"proscenium" in package.json must be an object' unless config.is_a?(Hash)
73
+
74
+ overrides = config['gemOverrides'] || {}
75
+ unless overrides.is_a?(Hash)
76
+ raise ConfigError, '"proscenium.gemOverrides" in package.json must be an object'
77
+ end
78
+
79
+ overrides.to_h do |gem, settings|
80
+ participate = settings.is_a?(Hash) ? settings['participate'] : nil
81
+ unless [true, false].include?(participate)
82
+ raise ConfigError,
83
+ "\"proscenium.gemOverrides.#{gem}.participate\" in package.json must be true " \
84
+ 'or false'
85
+ end
86
+
87
+ [gem, participate]
88
+ end
89
+ rescue JSON::ParserError => e
90
+ raise ConfigError, "package.json is not valid JSON (#{e.message.lines.first.strip})"
91
+ end
92
+
93
+ # Whether the gem's author opted it in, with gemspec metadata.
94
+ def opted_in?(spec) = spec.metadata['proscenium.dependencies'] == 'true'
95
+
96
+ # The gem's `proscenium.frontend_root`, relative to the gem: '' for its root, nil if it names a
97
+ # place outside the gem.
98
+ def frontend_root(spec)
99
+ relative = spec.metadata['proscenium.frontend_root'].to_s.delete_prefix('./').chomp('/')
100
+ return nil if relative.start_with?('/') || relative.split('/').include?('..')
101
+
102
+ relative
103
+ end
104
+
105
+ # The installed directory holding the gem's package.json, or nil if its frontend root is
106
+ # outside the gem.
107
+ def manifest_root(spec)
108
+ relative = frontend_root(spec)
109
+ return nil unless relative
110
+
111
+ relative.empty? ? spec.full_gem_path : File.join(spec.full_gem_path, relative)
112
+ end
113
+
114
+ # The participating installed specs, by name: opted in by their author or the app, and not
115
+ # opted out by the app.
116
+ def participating(specs = installed_specs, overrides: {})
117
+ specs.select { |spec| overrides.fetch(spec.name) { opted_in?(spec) } }.to_h { [it.name, it] }
118
+ end
119
+
120
+ # Installed gems that ship a root package.json but do not participate, for `inspect`.
121
+ def unparticipating_with_manifest(specs = installed_specs, overrides: {})
122
+ chosen = participating(specs, overrides:)
123
+ specs.reject { chosen.key?(it.name) }
124
+ .select { File.exist?(File.join(it.full_gem_path, 'package.json')) }
125
+ .map(&:name)
126
+ end
127
+
128
+ def pathname_for(name)
129
+ (path = paths[name]) ? Pathname(path) : nil
130
+ end
131
+
132
+ def pathname_for!(name)
133
+ unless (path = pathname_for(name))
134
+ raise "Gem `#{name}` not found in your Gemfile"
135
+ end
136
+
137
+ path
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proscenium
4
+ module CLI
5
+ # Quote-aware bracket scanning for the textual splices of pnpm-workspace.yaml flow lists and
6
+ # package.json (#154): a bracket or brace inside a quoted string is text, not structure.
7
+ module Brackets
8
+ module_function
9
+
10
+ # The index of the `]` closing the flow list or JSON array whose `[` ends at `start`, or nil:
11
+ # a bracket inside a quoted string, as a glob such as `packages/[ab]` has, or in a nested
12
+ # list, does not close it.
13
+ def array_end(text, start)
14
+ depth = 0
15
+ structure(text, start) do |char, index|
16
+ if char == '['
17
+ depth += 1
18
+ elsif char == ']'
19
+ return index if depth.zero?
20
+
21
+ depth -= 1
22
+ end
23
+ end
24
+ nil
25
+ end
26
+
27
+ # Where the first match of `key` from `from` at `depth` (1 for a JSON document's own keys)
28
+ # ends, or nil: another object's key of the same name is not it.
29
+ def key_end(text, key, depth, from = 0)
30
+ text.to_enum(:scan, key).map { Regexp.last_match }
31
+ .find { it.begin(0) >= from && depth(text, it.begin(0)) == depth }&.end(0)
32
+ end
33
+
34
+ # How deep `index` sits in `text`'s brackets and braces, outside quoted strings.
35
+ def depth(text, index)
36
+ nesting = { '[' => 1, '{' => 1, ']' => -1, '}' => -1 }
37
+ structure(text).take_while { |_, at| at < index }.sum { |char, _| nesting.fetch(char, 0) }
38
+ end
39
+
40
+ # Each character of `text` from `from` that is outside a quoted string or a YAML comment, with
41
+ # its index.
42
+ def structure(text, from = 0)
43
+ return enum_for(__method__, text, from) unless block_given?
44
+
45
+ quote = nil
46
+ index = from
47
+ while index < text.length
48
+ char = text[index]
49
+ if quote
50
+ index += 1 if char == '\\' && quote == '"'
51
+ quote = nil if char == quote
52
+ elsif ['"', "'"].include?(char)
53
+ quote = char
54
+ elsif char == '#' && (index.zero? || text[index - 1].match?(/[\s\[{,]/))
55
+ # A YAML comment, to the end of its line, after a space or a flow delimiter, as Psych
56
+ # reads it. JSON has no `#` outside a string.
57
+ index = (text.index("\n", index) || text.length) - 1
58
+ else
59
+ yield char, index
60
+ end
61
+ index += 1
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative '../context_map'
5
+ require_relative 'manager'
6
+
7
+ module Proscenium
8
+ module CLI
9
+ # The Bun linker an app uses today, written into bunfig.toml when the app sets none (#154,
10
+ # C48). Registering contexts adds `workspaces`, and with no linker set that alone can switch
11
+ # Bun from a hoisted node_modules to its isolated store (Stage A), so the first install pins
12
+ # the layout the app already has. Shown in the registration diff before it is written.
13
+ module BunLinker
14
+ # An `[install]` header, its name bare or quoted, and any comment after it.
15
+ HEADER = /^\[[ \t]*#{Manager::INSTALL}[ \t]*\][ \t]*(?:#[^\r\n]*)?(?=\r?$)/
16
+
17
+ module_function
18
+
19
+ # Bun's names for each layout: `node-linker`'s, and `install-strategy`'s, which node-linker
20
+ # overrides (probed on Bun 1.4.2).
21
+ NODE_LINKERS = { 'isolated' => 'isolated', 'pnpm' => 'isolated', 'hoisted' => 'hoisted',
22
+ 'npm' => 'hoisted' }.freeze
23
+ STRATEGIES = { 'linked' => 'isolated', 'hoisted' => 'hoisted', 'nested' => 'hoisted' }.freeze
24
+ NPMRC_LINKER = /^[ \t]*(node-linker|install-strategy)[ \t]*=[ \t]*["']?([^\s"']+)/
25
+
26
+ # The linker the project's .npmrc chooses. Otherwise `isolated` if Bun's isolated store is
27
+ # there, `hoisted` for any other node_modules. With nothing installed yet: what Bun would
28
+ # pick for the app as it stands, which is isolated only for an app that already declares
29
+ # workspaces under a lock of configVersion 1 or later.
30
+ def current(root)
31
+ chosen = configured(root)
32
+ return chosen if chosen
33
+
34
+ modules = File.join(root, 'node_modules')
35
+ return 'isolated' if File.directory?(File.join(modules, '.bun'))
36
+ return 'hoisted' if File.directory?(modules)
37
+
38
+ workspaces?(root) && config_version(root) >= 1 ? 'isolated' : 'hoisted'
39
+ end
40
+
41
+ # bunfig.toml's text with `linker` set in its `[install]` table, adding the table if need be.
42
+ def splice(text, linker)
43
+ line = "linker = \"#{linker}\""
44
+ eol = text.include?("\r\n") ? "\r\n" : "\n"
45
+ if (header = text.match(HEADER))
46
+ text.dup.insert(header.end(0), "#{eol}#{line}")
47
+ else
48
+ separator = text.empty? || text.end_with?("\n") ? '' : eol
49
+ "#{text}#{separator}#{eol unless text.empty?}[install]#{eol}#{line}#{eol}"
50
+ end
51
+ end
52
+
53
+ # The linker the project's own .npmrc sets. Bun also reads the developer's ~/.npmrc,
54
+ # $XDG_CONFIG_HOME/.npmrc and global .bunfig.toml, but this is committed for the whole team,
55
+ # so only the project decides it.
56
+ def configured(root) = npmrc_linker(File.join(root, '.npmrc'))
57
+
58
+ # The linker an .npmrc sets, by node-linker or else install-strategy, or nil.
59
+ def npmrc_linker(path)
60
+ return unless File.exist?(path)
61
+
62
+ settings = File.read(path).scan(NPMRC_LINKER).to_h
63
+ NODE_LINKERS[settings['node-linker']] || STRATEGIES[settings['install-strategy']]
64
+ end
65
+
66
+ def workspaces?(root)
67
+ path = File.join(root, 'package.json')
68
+ File.exist?(path) && ContextMap.read_json(path).key?('workspaces')
69
+ rescue JSON::ParserError
70
+ false
71
+ end
72
+
73
+ def config_version(root)
74
+ path = File.join(root, 'bun.lock')
75
+ File.exist?(path) ? File.read(path)[/"configVersion":\s*(\d+)/, 1].to_i : 0
76
+ end
77
+ end
78
+ end
79
+ end