proscenium 0.25.3 → 0.26.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/README.md +170 -23
- data/exe/proscenium +19 -0
- data/exe/psm +5 -0
- data/lib/proscenium/app_package.rb +90 -0
- data/lib/proscenium/builder.rb +221 -20
- data/lib/proscenium/bundled_gems.rb +103 -0
- data/lib/proscenium/cli/brackets.rb +66 -0
- data/lib/proscenium/cli/bun_linker.rb +79 -0
- data/lib/proscenium/cli/capabilities.json +29 -0
- data/lib/proscenium/cli/collisions.rb +94 -0
- data/lib/proscenium/cli/contexts.rb +133 -0
- data/lib/proscenium/cli/diff.rb +80 -0
- data/lib/proscenium/cli/error.rb +291 -0
- data/lib/proscenium/cli/gem_archive.rb +44 -0
- data/lib/proscenium/cli/gem_check.rb +150 -0
- data/lib/proscenium/cli/gitignore.rb +63 -0
- data/lib/proscenium/cli/inspect.rb +257 -0
- data/lib/proscenium/cli/install.rb +378 -0
- data/lib/proscenium/cli/manager.rb +323 -0
- data/lib/proscenium/cli/platform_variants.rb +82 -0
- data/lib/proscenium/cli/pnpm_approvals.rb +109 -0
- data/lib/proscenium/cli/pnpm_workspace.rb +149 -0
- data/lib/proscenium/cli/project_lock.rb +106 -0
- data/lib/proscenium/cli/registration.rb +140 -0
- data/lib/proscenium/cli/reporter.rb +103 -0
- data/lib/proscenium/cli/rules.rb +169 -0
- data/lib/proscenium/cli/runner.rb +142 -0
- data/lib/proscenium/cli/verify.rb +107 -0
- data/lib/proscenium/cli.rb +122 -0
- data/lib/proscenium/context_map.rb +181 -0
- data/lib/proscenium/css_module/transformer.rb +18 -3
- data/lib/proscenium/css_module.rb +5 -6
- data/lib/proscenium/dependency_context.rb +74 -0
- data/lib/proscenium/error.rb +10 -0
- data/lib/proscenium/helper.rb +38 -4
- data/lib/proscenium/importer.rb +53 -42
- data/lib/proscenium/manifest.rb +18 -23
- data/lib/proscenium/mapping_generation.rb +64 -0
- data/lib/proscenium/middleware/base.rb +5 -10
- data/lib/proscenium/middleware/chunks.rb +5 -0
- data/lib/proscenium/monkey.rb +51 -69
- data/lib/proscenium/railtie.rb +16 -0
- data/lib/proscenium/railties/assets.rake +2 -1
- data/lib/proscenium/resolver.rb +43 -11
- data/lib/proscenium/runtime/bootstrap.js +26 -6
- data/lib/proscenium/runtime/server.rb +57 -36
- data/lib/proscenium/side_load.rb +75 -18
- data/lib/proscenium/stale_contexts.rb +118 -0
- data/lib/proscenium/templates/rescues/build_error.html.erb +9 -7
- data/lib/proscenium/utils.rb +55 -0
- data/lib/proscenium/version.rb +1 -1
- data/lib/proscenium/yaml_tree.rb +48 -0
- data/lib/proscenium.rb +7 -4
- metadata +42 -13
- data/lib/proscenium/ext/proscenium +0 -0
- data/lib/proscenium/ext/proscenium.h +0 -113
- data/lib/proscenium/react-manager/index.jsx +0 -102
- data/lib/proscenium/react-manager/react.js +0 -2
- data/lib/proscenium/react_componentable.rb +0 -94
data/lib/proscenium/builder.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'ffi'
|
|
4
|
+
require 'pathname'
|
|
5
|
+
require_relative 'error'
|
|
4
6
|
|
|
5
7
|
module Proscenium
|
|
6
8
|
class Builder
|
|
@@ -23,16 +25,81 @@ module Proscenium
|
|
|
23
25
|
:messages, :pointer
|
|
24
26
|
end
|
|
25
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
|
+
|
|
26
58
|
module Request
|
|
27
59
|
extend FFI::Library
|
|
28
60
|
|
|
29
|
-
|
|
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
|
|
30
94
|
|
|
31
95
|
enum :environment, [:development, 1, :test, :production]
|
|
32
96
|
|
|
33
97
|
# `blocking: true` releases the GVL for the duration of the call, so a build/resolve
|
|
34
98
|
# doesn't stall unrelated Ruby threads (eg. other requests in a multi-threaded server).
|
|
35
|
-
# Safe to do because the Go side
|
|
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.
|
|
36
103
|
|
|
37
104
|
attach_function :build_to_string, [
|
|
38
105
|
:string, # Path or entry point.
|
|
@@ -56,17 +123,54 @@ module Proscenium
|
|
|
56
123
|
class BuildError < Error
|
|
57
124
|
attr_reader :error, :path
|
|
58
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
|
+
|
|
59
155
|
def initialize(path, error)
|
|
60
156
|
@path = path
|
|
61
|
-
@error =
|
|
157
|
+
@error = self.class.parse_json(error) || { 'Text' => error.to_s }
|
|
62
158
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
68
167
|
|
|
69
|
-
|
|
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")}")
|
|
70
174
|
end
|
|
71
175
|
end
|
|
72
176
|
|
|
@@ -91,7 +195,51 @@ module Proscenium
|
|
|
91
195
|
new(root:, **overrides).compile
|
|
92
196
|
end
|
|
93
197
|
|
|
94
|
-
#
|
|
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.
|
|
95
243
|
def self.reset_config!
|
|
96
244
|
Request.reset_config
|
|
97
245
|
end
|
|
@@ -102,8 +250,25 @@ module Proscenium
|
|
|
102
250
|
# - than the app's own configuration. Keys must match `types.ConfigT`; Go silently ignores
|
|
103
251
|
# any it does not know.
|
|
104
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)
|
|
105
270
|
config_hash = {
|
|
106
|
-
RootPath: (root || Rails.root).to_s,
|
|
271
|
+
RootPath: (@root || Rails.root).to_s,
|
|
107
272
|
OutputDir: "public#{Proscenium.config.output_dir}",
|
|
108
273
|
GemPath: gem_root,
|
|
109
274
|
Environment: ENVIRONMENTS.fetch(Rails.env.to_sym, 2),
|
|
@@ -114,11 +279,13 @@ module Proscenium
|
|
|
114
279
|
Aliases: Proscenium.config.aliases,
|
|
115
280
|
External: Proscenium.config.external,
|
|
116
281
|
Precompile: Proscenium.config.precompile,
|
|
117
|
-
Debug: Proscenium.config.debug
|
|
118
|
-
|
|
282
|
+
Debug: Proscenium.config.debug,
|
|
283
|
+
**ContextMap.config(ContextMap.project_root, generation)
|
|
284
|
+
}.merge(@overrides)
|
|
119
285
|
|
|
120
|
-
|
|
286
|
+
self.class.request_config_pointer(config_hash)
|
|
121
287
|
end
|
|
288
|
+
private :configure
|
|
122
289
|
|
|
123
290
|
class << self
|
|
124
291
|
# Building the config JSON and copying it into an FFI::MemoryPointer is the only real cost
|
|
@@ -146,8 +313,12 @@ module Proscenium
|
|
|
146
313
|
end
|
|
147
314
|
|
|
148
315
|
def build_to_string(path)
|
|
316
|
+
if (reason = self.class.refusal(@root || Rails.root))
|
|
317
|
+
raise BuildError.new(path, reason)
|
|
318
|
+
end
|
|
319
|
+
|
|
149
320
|
ActiveSupport::Notifications.instrument('build.proscenium', identifier: path) do
|
|
150
|
-
raw = Request.build_to_string(path,
|
|
321
|
+
raw = Request.build_to_string(path, request_config)
|
|
151
322
|
result = { success: raw[:success], response: read_and_free(raw[:response]),
|
|
152
323
|
content_hash: read_and_free(raw[:content_hash]) }
|
|
153
324
|
|
|
@@ -158,8 +329,12 @@ module Proscenium
|
|
|
158
329
|
end
|
|
159
330
|
|
|
160
331
|
def resolve(path)
|
|
332
|
+
if (reason = self.class.refusal(@root || Rails.root))
|
|
333
|
+
raise ResolveError.new(path, reason)
|
|
334
|
+
end
|
|
335
|
+
|
|
161
336
|
ActiveSupport::Notifications.instrument('resolve.proscenium', identifier: path) do
|
|
162
|
-
raw = Request.resolve(path,
|
|
337
|
+
raw = Request.resolve(path, request_config)
|
|
163
338
|
success = raw[:success]
|
|
164
339
|
url_path = read_and_free(raw[:url_path])
|
|
165
340
|
abs_path = read_and_free(raw[:abs_path])
|
|
@@ -170,9 +345,17 @@ module Proscenium
|
|
|
170
345
|
end
|
|
171
346
|
end
|
|
172
347
|
|
|
348
|
+
# Returns true, or raises CompileError with esbuild's messages.
|
|
173
349
|
def compile
|
|
174
|
-
|
|
175
|
-
|
|
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
|
+
|
|
176
359
|
raw[:success]
|
|
177
360
|
end
|
|
178
361
|
|
|
@@ -180,19 +363,37 @@ module Proscenium
|
|
|
180
363
|
|
|
181
364
|
# The Go side allocates each of these strings with C.CString, which the Go runtime cannot
|
|
182
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).
|
|
183
370
|
def read_and_free(ptr)
|
|
184
371
|
return nil if ptr.null?
|
|
185
372
|
|
|
186
|
-
ptr.read_string
|
|
373
|
+
ptr.read_string.force_encoding(Encoding::UTF_8)
|
|
187
374
|
ensure
|
|
188
375
|
Request.free_cstr(ptr)
|
|
189
376
|
end
|
|
190
377
|
|
|
191
378
|
# Build the ENV variables as determined by `Proscenium.config.env_vars` and
|
|
192
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.
|
|
193
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
|
+
|
|
194
395
|
ENV['NODE_ENV'] = ENV.fetch('RAILS_ENV', nil)
|
|
195
|
-
ENV.slice(*Proscenium
|
|
396
|
+
ENV.slice(*Proscenium::DEFAULT_ENV_VARS, *names)
|
|
196
397
|
end
|
|
197
398
|
|
|
198
399
|
def gem_root
|
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'error'
|
|
5
|
+
|
|
3
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.
|
|
4
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
|
+
|
|
5
15
|
module_function
|
|
6
16
|
|
|
7
17
|
def paths
|
|
@@ -22,6 +32,99 @@ module Proscenium
|
|
|
22
32
|
end
|
|
23
33
|
end
|
|
24
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
|
+
|
|
25
128
|
def pathname_for(name)
|
|
26
129
|
(path = paths[name]) ? Pathname(path) : nil
|
|
27
130
|
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
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": 1,
|
|
3
|
+
"comment": "The pnpm and Bun lines Proscenium supports (#154). The source of truth: the CLI checks manager versions against it, and CI pins the versions it lists. `ci` is the exact version CI runs; `eol` is when the line's owner stops maintaining it. pnpm 10 is not supported (decided 4 October 2026): it leaves a context on an older copy of a shared peer after the app upgrades it, and 11 and 12 do not. pnpm 11 starts at 11.11.0: earlier 11.x let a package spoofing an approved name in its manifest run its build scripts (GHSA-5wx6-mg75-v57r, fixed in 11.5.3) and a tarball's manifest name write outside node_modules (GHSA-vq4v-j7r6-jq4m, fixed in 11.11.0).",
|
|
4
|
+
"managers": {
|
|
5
|
+
"pnpm": {
|
|
6
|
+
"lines": [
|
|
7
|
+
{
|
|
8
|
+
"range": ">= 11.11.0, < 12",
|
|
9
|
+
"ci": "11.28.4",
|
|
10
|
+
"eol": "2027-04-30"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"range": ">= 12.0.0, < 13",
|
|
14
|
+
"ci": "12.9.1",
|
|
15
|
+
"eol": null
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
"bun": {
|
|
20
|
+
"lines": [
|
|
21
|
+
{
|
|
22
|
+
"range": ">= 1.4.0, < 1.5",
|
|
23
|
+
"ci": "1.4.2",
|
|
24
|
+
"eol": null
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|