proscenium 0.25.3 → 0.26.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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +170 -23
  3. data/exe/proscenium +19 -0
  4. data/exe/psm +5 -0
  5. data/lib/proscenium/app_package.rb +90 -0
  6. data/lib/proscenium/builder.rb +221 -20
  7. data/lib/proscenium/bundled_gems.rb +103 -0
  8. data/lib/proscenium/cli/brackets.rb +66 -0
  9. data/lib/proscenium/cli/bun_linker.rb +79 -0
  10. data/lib/proscenium/cli/capabilities.json +29 -0
  11. data/lib/proscenium/cli/collisions.rb +94 -0
  12. data/lib/proscenium/cli/contexts.rb +133 -0
  13. data/lib/proscenium/cli/diff.rb +80 -0
  14. data/lib/proscenium/cli/error.rb +291 -0
  15. data/lib/proscenium/cli/gem_archive.rb +44 -0
  16. data/lib/proscenium/cli/gem_check.rb +150 -0
  17. data/lib/proscenium/cli/gitignore.rb +63 -0
  18. data/lib/proscenium/cli/inspect.rb +257 -0
  19. data/lib/proscenium/cli/install.rb +378 -0
  20. data/lib/proscenium/cli/manager.rb +323 -0
  21. data/lib/proscenium/cli/platform_variants.rb +82 -0
  22. data/lib/proscenium/cli/pnpm_approvals.rb +109 -0
  23. data/lib/proscenium/cli/pnpm_workspace.rb +149 -0
  24. data/lib/proscenium/cli/project_lock.rb +106 -0
  25. data/lib/proscenium/cli/registration.rb +140 -0
  26. data/lib/proscenium/cli/reporter.rb +103 -0
  27. data/lib/proscenium/cli/rules.rb +169 -0
  28. data/lib/proscenium/cli/runner.rb +142 -0
  29. data/lib/proscenium/cli/verify.rb +107 -0
  30. data/lib/proscenium/cli.rb +122 -0
  31. data/lib/proscenium/context_map.rb +181 -0
  32. data/lib/proscenium/css_module/transformer.rb +18 -3
  33. data/lib/proscenium/css_module.rb +5 -6
  34. data/lib/proscenium/dependency_context.rb +74 -0
  35. data/lib/proscenium/error.rb +10 -0
  36. data/lib/proscenium/helper.rb +38 -4
  37. data/lib/proscenium/importer.rb +53 -42
  38. data/lib/proscenium/manifest.rb +18 -23
  39. data/lib/proscenium/mapping_generation.rb +64 -0
  40. data/lib/proscenium/middleware/base.rb +5 -10
  41. data/lib/proscenium/middleware/chunks.rb +5 -0
  42. data/lib/proscenium/monkey.rb +51 -69
  43. data/lib/proscenium/railtie.rb +16 -0
  44. data/lib/proscenium/railties/assets.rake +2 -1
  45. data/lib/proscenium/resolver.rb +43 -11
  46. data/lib/proscenium/runtime/bootstrap.js +26 -6
  47. data/lib/proscenium/runtime/server.rb +57 -36
  48. data/lib/proscenium/side_load.rb +75 -18
  49. data/lib/proscenium/stale_contexts.rb +118 -0
  50. data/lib/proscenium/templates/rescues/build_error.html.erb +9 -7
  51. data/lib/proscenium/utils.rb +55 -0
  52. data/lib/proscenium/version.rb +1 -1
  53. data/lib/proscenium/yaml_tree.rb +48 -0
  54. data/lib/proscenium.rb +7 -4
  55. metadata +42 -13
  56. data/lib/proscenium/ext/proscenium +0 -0
  57. data/lib/proscenium/ext/proscenium.h +0 -113
  58. data/lib/proscenium/react-manager/index.jsx +0 -102
  59. data/lib/proscenium/react-manager/react.js +0 -2
  60. data/lib/proscenium/react_componentable.rb +0 -94
@@ -1,12 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'active_support/current_attributes'
4
+ require 'concurrent/map'
4
5
 
5
6
  module Proscenium
6
7
  class Importer < ActiveSupport::CurrentAttributes
7
8
  JS_EXTENSIONS = %w[.tsx .ts .jsx .js].freeze
8
9
  CSS_EXTENSIONS = %w[.module.css .css].freeze
9
10
 
11
+ # The readable suffix of a CSS module class name, by absolute path - or by URL, for a remote
12
+ # module, which has no file. It is a pure function of that identity, but `import` runs once per
13
+ # class name a view emits and `relative_path_from` is most of its cost, so it is built once per
14
+ # file for the life of the process.
15
+ SUFFIXES = Concurrent::Map.new
16
+
10
17
  # Holds the JS and CSS files to include in the current request.
11
18
  #
12
19
  # Example:
@@ -21,59 +28,45 @@ module Proscenium
21
28
  class << self
22
29
  # Import the given `filepath`. This is idempotent - it will never include duplicates.
23
30
  #
24
- # @param filepath [String] Absolute URL path (relative to Rails root) of the file to import.
25
- # Should be the actual asset file, eg. app.css, some/component.js.
31
+ # @param filepath [String] Absolute URL path (relative to Rails root) of the file to import,
32
+ # or a URL, which has no file on disk. Should be the actual asset file, eg. app.css,
33
+ # some/component.js.
26
34
  # @return [String|nil] the digest of the imported file path if a css module (*.module.css).
27
35
  def import(filepath = nil, sideloaded: false, **)
28
36
  self.imported ||= {}
29
37
 
30
- digest = nil
31
-
32
38
  if filepath.end_with?('.module.css')
33
- if imported.key?(filepath)
34
- digest = imported[filepath][:digest]
35
- abs_path = imported[filepath][:abs_path]
36
- else
37
- manifest_path, non_manifest_path, abs_path = Resolver.resolve(filepath, as_array: true)
38
- digest = Utils.css_module_digest(abs_path)
39
- filepath = Array(manifest_path || non_manifest_path)[0]
40
-
41
- if sideloaded
42
- ActiveSupport::Notifications.instrument 'sideload.proscenium', identifier: filepath,
43
- sideloaded: do
44
- imported[filepath] = { ** }
45
- imported[filepath][:digest] = digest
46
- imported[filepath][:abs_path] = abs_path
47
- end
48
- else
49
- imported[filepath] = { ** }
50
- imported[filepath][:digest] = digest
51
- imported[filepath][:abs_path] = abs_path
52
- end
53
- end
39
+ manifest_path, non_manifest_path, abs_path = Resolver.resolve(filepath, as_array: true)
40
+ filepath = Array(manifest_path || non_manifest_path)[0]
41
+
42
+ # A URL has no file on disk, so the URL is the module's identity. Taken after the
43
+ # reassignment above, so the digest and the suffix cache below agree on it. An entry the
44
+ # stylesheet got as a plain file has no digest of its own.
45
+ digest = imported.dig(filepath, :digest) ||
46
+ Utils.css_module_digest(abs_path.presence || filepath)
47
+ store(filepath, sideloaded, **, digest:)
54
48
 
55
49
  transformed_path = ''
56
50
  # Mirrors ConfigT#ShouldMinify - the suffix exists whenever identifiers are not
57
51
  # minified, and a class name the stylesheet does not define is worse than a long one.
58
52
  if Proscenium.config.debug || !Rails.env.production?
59
- rel_path = Pathname.new(abs_path).relative_path_from(Rails.root).sub_ext('')
60
- transformed_path = "_#{rel_path.to_s.gsub(%r{[@/.+]}, '-')}"
53
+ # Keyed and built from the file's path under the app, or from the URL when there is
54
+ # no file: `Pathname.new('').relative_path_from(Rails.root)` raises.
55
+ identity = abs_path.presence || filepath
56
+ transformed_path = SUFFIXES.compute_if_absent(identity) do
57
+ relative = if abs_path.present?
58
+ Utils.css_module_relative_path(abs_path)
59
+ else
60
+ identity
61
+ end
62
+
63
+ "_#{Utils.css_module_suffix(relative)}"
64
+ end
61
65
  end
62
66
 
63
67
  "#{digest}#{transformed_path}"
64
68
  else
65
- return if imported.key?(filepath)
66
-
67
- Array(Resolver.resolve(filepath)).each do |fp|
68
- if sideloaded
69
- ActiveSupport::Notifications.instrument 'sideload.proscenium', identifier: fp,
70
- sideloaded: do
71
- imported[fp] = { ** }
72
- end
73
- else
74
- imported[fp] = { ** }
75
- end
76
- end
69
+ Array(Resolver.resolve(filepath)).each { |fp| store(fp, sideloaded, **) }
77
70
  end
78
71
  end
79
72
 
@@ -118,9 +111,8 @@ module Proscenium
118
111
 
119
112
  # @param filepath [Pathname] Absolute file system path of the Ruby file to sideload.
120
113
  # @param extensions [Array<String>] Supported file extensions to sideload.
121
- # @param options [Hash] Options to pass to `import`.
122
114
  # @raise [ArgumentError] if `filepath` is not an absolute file system path.
123
- private def _sideload(filepath, extensions, **options) # rubocop:disable Style/AccessModifierDeclarations
115
+ private def _sideload(filepath, extensions, **) # rubocop:disable Style/AccessModifierDeclarations
124
116
  return unless Proscenium.config.side_load
125
117
 
126
118
  if !filepath.is_a?(Pathname) || !filepath.absolute?
@@ -133,7 +125,7 @@ module Proscenium
133
125
  extensions.find do |x|
134
126
  next unless (fp = filepath.sub_ext(x)).exist?
135
127
 
136
- import(fp.to_s, sideloaded: filepath, **options)
128
+ import(fp.to_s, sideloaded: filepath, **)
137
129
  end
138
130
  end
139
131
 
@@ -173,6 +165,25 @@ module Proscenium
173
165
  def imported?(filepath = nil)
174
166
  filepath ? imported&.key?(filepath) : !imported.blank?
175
167
  end
168
+
169
+ private
170
+
171
+ # Keyed by the resolved path, never the path given: a sideload passes an absolute file
172
+ # system path. A repeat import merges its options in, later ones winning key by key, and does
173
+ # not notify again; proscenium-ui's Select sideloads itself `lazy: true` over an eager
174
+ # sideload of the same file.
175
+ def store(key, sideloaded, **options)
176
+ if (existing = imported[key])
177
+ existing.merge!(options)
178
+ elsif sideloaded
179
+ ActiveSupport::Notifications.instrument 'sideload.proscenium', identifier: key,
180
+ sideloaded: do
181
+ imported[key] = options
182
+ end
183
+ else
184
+ imported[key] = options
185
+ end
186
+ end
176
187
  end
177
188
  end
178
189
  end
@@ -3,51 +3,46 @@
3
3
  module Proscenium
4
4
  module Manifest
5
5
  mattr_accessor :manifest, default: {}
6
- mattr_accessor :loaded, default: false
7
6
 
8
7
  module_function
9
8
 
10
- def loaded?
11
- loaded
12
- end
13
-
9
+ # Built aside and assigned once, so a resolve during a load never sees a half-built manifest,
10
+ # and a manifest that fails to parse leaves the previous one in place.
14
11
  def load!
15
12
  public_path = Rails.configuration.paths['public'].first
16
- self.manifest = {}
17
- self.loaded = false
13
+ entries = {}
18
14
 
19
15
  if Proscenium.config.manifest_path.exist?
20
- self.loaded = true
21
-
22
16
  JSON.parse(Proscenium.config.manifest_path.read)['outputs'].each do |outpath, details|
23
17
  next if !details.key?('entryPoint')
24
18
 
25
- outpath = outpath.delete_prefix "#{public_path}/"
19
+ outpath = fs_path(outpath).delete_prefix "#{public_path}/"
26
20
 
27
- ep = details['entryPoint']
28
- ep = if (gem = BundledGems.paths.find { |_, v| ep.start_with? "#{v}/" })
29
- "@rubygems/#{gem[0]}#{ep.delete_prefix(gem[1])}"
30
- else
31
- ep.delete_prefix(Rails.root.to_s)
32
- end
21
+ ep = fs_path(details['entryPoint'])
22
+ ep = BundledGems.virtual_path(ep) || ep.delete_prefix(Rails.root.to_s)
33
23
 
34
- manifest[ep] = [
24
+ entries[ep] = [
35
25
  "/#{outpath}",
36
- details['cssBundle']&.delete_prefix(public_path)
26
+ details['cssBundle'] && fs_path(details['cssBundle']).delete_prefix(public_path)
37
27
  ].compact
38
28
  end
39
29
  end
40
30
 
41
- manifest
31
+ self.manifest = entries
42
32
  end
43
33
 
34
+ # esbuild records absolute paths in the metafile in the form the platform produced. Left in
35
+ # Windows form, every delete_prefix above failed, the manifest was keyed by full Windows
36
+ # paths, and every lookup missed: the app served source paths instead of the digest URLs it
37
+ # had just compiled, silently.
38
+ def fs_path(path) = Utils.fs_path(path)
39
+
40
+ # Empty, not nil or a lazy re-read: the runtime daemon relies on a reset manifest staying
41
+ # empty until `load!`.
44
42
  def reset!
45
43
  self.manifest = {}
46
- self.loaded = false
47
44
  end
48
45
 
49
- def [](key)
50
- loaded? ? manifest[key] : nil
51
- end
46
+ def [](key) = manifest[key]
52
47
  end
53
48
  end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'bundled_gems'
4
+ require_relative 'context_map'
5
+ require_relative 'stale_contexts'
6
+
7
+ module Proscenium
8
+ # Which mapping generation a build uses (#154). A generation is the context map and the
9
+ # staleness verdict, read once and kept, so every build sees one consistent view. In production
10
+ # a generation lasts the process: restart after a deploy. In development and test, `refresh`
11
+ # looks at most once a second for a change to the files the map is built from, and starts a new
12
+ # generation when one changed, such as `proscenium install` finishing.
13
+ #
14
+ # Gemfile.lock is watched, but a gem added or upgraded still needs a restart: Bundler loads the
15
+ # bundle once per process.
16
+ module MappingGeneration
17
+ # The lockfiles too: with contexts adopted, resolved paths carry each package's version.
18
+ WATCHED = ['Gemfile.lock', 'package.json', 'pnpm-workspace.yaml', '.npmrc',
19
+ 'bunfig.toml', 'pnpm-lock.yaml', 'bun.lock', ContextMap::INSTALLING].freeze
20
+ EVERY = 1.0
21
+ LOCK = Mutex.new
22
+
23
+ module_function
24
+
25
+ # The current generation number, starting a new one first if a watched file changed and the
26
+ # last look was at least `every` seconds ago. Returns [number, whether it just changed].
27
+ def refresh(root, every: EVERY)
28
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
29
+ LOCK.synchronize do
30
+ @looked ||= {}
31
+ @number ||= 0
32
+ at, before = @looked[root]
33
+ return [@number, false] if at && now - at < every
34
+
35
+ after = signature(root)
36
+ @looked[root] = [now, after]
37
+ return [@number, false] if before.nil? || before == after
38
+
39
+ ContextMap.reset!
40
+ StaleContexts.reset!
41
+ [@number += 1, true]
42
+ end
43
+ end
44
+
45
+ # The modification time of every file the map is built from, by path, nil for a missing one.
46
+ def signature(root)
47
+ files = WATCHED.map { File.join(root, it) } +
48
+ Dir.glob(File.join(root, ContextMap::CONTEXTS, '*', 'package.json')) +
49
+ manifests(root)
50
+ files.sort.to_h { [it, File.exist?(it) ? File.mtime(it).to_f : nil] }
51
+ end
52
+
53
+ # Each participating gem's package.json: editing a path gem's manifest makes its context stale.
54
+ def manifests(root)
55
+ BundledGems.participating(BundledGems.installed_specs, overrides: BundledGems.overrides(root))
56
+ .values.filter_map { BundledGems.manifest_root(it) }
57
+ .map { File.join(it, 'package.json') }
58
+ rescue BundledGems::ConfigError
59
+ []
60
+ end
61
+
62
+ def reset! = LOCK.synchronize { @looked = {} }
63
+ end
64
+ end
@@ -48,9 +48,12 @@ module Proscenium
48
48
  file_readable?
49
49
  end
50
50
 
51
+ # Probes the path exactly as it will be built. `real_path` is already decoded and cleaned, so
52
+ # decoding it again here - as this once did - probed a different file than the one built
53
+ # whenever the request was double-encoded: `%252e%252e` was built as a literal `%2e%2e`
54
+ # segment but probed as `..`.
51
55
  def file_readable?
52
- return false unless (path = clean_path(sourcemap? ? real_path[0...-4] : real_path))
53
-
56
+ path = sourcemap? ? real_path[0...-4] : real_path
54
57
  file_stat = File.stat(root_for_readable.join(path.delete_prefix('/').b).to_s)
55
58
  rescue SystemCallError
56
59
  false
@@ -62,14 +65,6 @@ module Proscenium
62
65
  Rails.root
63
66
  end
64
67
 
65
- # Secondary to `Middleware.normalise_path`, which every path here has already been through
66
- # - this is idempotent on that output, and earns its keep by stripping the leading slash
67
- # that the `join` above needs, and by handling the `.map`-stripped variant.
68
- def clean_path(file)
69
- path = Rack::Utils.unescape_path file.chomp('/').delete_prefix('/')
70
- Rack::Utils.clean_path_info path if Rack::Utils.valid_path? path
71
- end
72
-
73
68
  def content_type
74
69
  case ::File.extname(path_to_build)
75
70
  when '.js', '.mjs', '.ts', '.tsx', '.jsx' then 'application/javascript'
@@ -22,7 +22,12 @@ module Proscenium
22
22
  # The path also has to yield a content hash, not just match the prefix - `CHUNKS_PATH`
23
23
  # checks only the latter, and `/_asset_chunks/nohash.js` used to index the nil match and
24
24
  # return a 500 from here.
25
+ #
26
+ # FileHandler decodes `PATH_INFO` again, and that cannot be switched off, so a path still
27
+ # holding a `%` after the first decode is refused: the handler would resolve it to a
28
+ # different file than the one checked here. esbuild never puts a `%` in a chunk name.
25
29
  return @app.call(env) unless (path = Middleware.normalise_path(request.path)) &&
30
+ !path.include?('%') &&
26
31
  path.match?(CHUNKS_PATH) &&
27
32
  (etag = path[/-\$([a-z0-9]+)\$/i, 1])
28
33
 
@@ -2,47 +2,39 @@
2
2
 
3
3
  module Proscenium
4
4
  module Monkey
5
- module TemplateRenderer
6
- private
7
-
8
- def render_template(view, template, layout_name, locals)
9
- result = super
10
- return result if !view.controller || !Proscenium.config.side_load
11
-
12
- to_sideload = if template.respond_to?(:identifier) &&
13
- template.respond_to?(:type) && template.type == :html
14
- template
15
- end
16
- if to_sideload && view.controller.respond_to?(:sideload_assets_options)
17
- options = view.controller.sideload_assets_options
18
- layout = find_layout(layout_name, locals.keys, [formats.first])
19
- sideload_template_assets layout, view.controller, options if layout
20
- sideload_template_assets to_sideload, view.controller, options
21
- end
22
-
23
- result
5
+ # A template ActionView pushes onto `@current_template` is not the body of a partial rendered
6
+ # with a block, so `Helper#sideload_assets` must not see that partial while one renders. Rails
7
+ # caches template objects, so the pushed template can be the very one that rendered the partial.
8
+ #
9
+ # Overrides ActionView's private `ActionView::Base#_run(method, template, locals, buffer,
10
+ # add_to_stack: true, ...)`, unchanged from Rails 7.2 to 8.1. `@proscenium_block_partial` is
11
+ # set by `Helper#proscenium_render_block_partial`. Every template render runs through here, so
12
+ # arguments are forwarded with `...`, which allocates nothing. Reading `add_to_stack` does
13
+ # allocate, so it is only read while a block partial is rendering.
14
+ module Base
15
+ def _run(...)
16
+ return super unless @proscenium_block_partial && proscenium_pushes_template?(...)
17
+
18
+ owner = @proscenium_block_partial
19
+ @proscenium_block_partial = nil
20
+ super
21
+ ensure
22
+ @proscenium_block_partial = owner if owner
24
23
  end
25
24
 
26
- def sideload_template_assets(tpl, controller, options)
27
- return unless (tpl_path = Pathname.new(tpl.identifier)).file?
25
+ private
28
26
 
29
- options = {} if options.nil?
30
- options = { js: options, css: options } unless options.is_a?(Hash)
27
+ def proscenium_pushes_template?(*, add_to_stack: true, **) = add_to_stack
28
+ end
31
29
 
32
- if tpl.instance_variable_defined?(:@sideload_assets_options)
33
- tpl_options = tpl.instance_variable_get(:@sideload_assets_options)
34
- options = case tpl_options
35
- when Hash then options.deep_merge(tpl_options)
36
- else
37
- { js: tpl_options, css: tpl_options }
38
- end
39
- end
30
+ module TemplateRenderer
31
+ private
40
32
 
41
- %i[css js].each do |k|
42
- options[k] = controller.instance_eval(&options[k]) if options[k].is_a?(Proc)
43
- end
33
+ def render_template(view, template, layout_name, locals)
34
+ return super unless SideLoad.sideloadable?(view, template)
44
35
 
45
- Importer.sideload tpl_path, **options
36
+ layout = find_layout(layout_name, locals.keys, [formats.first])
37
+ SideLoad.sideload_templates(view, [layout, template]) { super }
46
38
  end
47
39
  end
48
40
 
@@ -50,45 +42,35 @@ module Proscenium
50
42
  private
51
43
 
52
44
  def render_partial_template(view, locals, template, layout, block)
53
- result = super
54
-
55
- return result if !view.controller || !Proscenium.config.side_load
56
-
57
- if template.respond_to?(:identifier) &&
58
- template.respond_to?(:type) && template.type == :html &&
59
- view.controller.respond_to?(:sideload_assets_options)
60
- options = view.controller.sideload_assets_options
61
- sideload_template_assets layout, view.controller, options if layout
62
- sideload_template_assets template, view.controller, options
45
+ return super unless SideLoad.sideloadable?(view, template)
46
+
47
+ SideLoad.sideload_templates(view, [layout, template]) do
48
+ # A view rendered before Proscenium::Helper is included cannot call `sideload_assets`.
49
+ if block && view.respond_to?(:proscenium_render_block_partial)
50
+ view.proscenium_render_block_partial(template, block) do |wrapped|
51
+ super(view, locals, template, layout, wrapped)
52
+ end
53
+ else
54
+ super
55
+ end
63
56
  end
64
-
65
- result
66
57
  end
58
+ end
67
59
 
68
- # `controller` is passed in rather than read off `self`: unlike TemplateRenderer, an
69
- # ActionView::PartialRenderer has no `controller` - so a Proc option reaching a partial
70
- # raised NameError until it was threaded through here, as its TemplateRenderer twin
71
- # already did.
72
- def sideload_template_assets(tpl, controller, options)
73
- return unless (tpl_path = Pathname.new(tpl.identifier)).file?
74
-
75
- options = {} if options.nil?
76
- options = { js: options, css: options } unless options.is_a?(Hash)
77
-
78
- if tpl.instance_variable_defined?(:@sideload_assets_options)
79
- tpl_options = tpl.instance_variable_get(:@sideload_assets_options)
80
- options = if tpl_options.is_a?(Hash)
81
- options.deep_merge tpl_options
82
- else
83
- { js: tpl_options, css: tpl_options }
84
- end
85
- end
60
+ # A collection renders each item with `Template#render`, so never reaches
61
+ # `PartialRenderer#render_partial_template`. Hooked above the collection cache, which skips
62
+ # rendering entirely on a full hit. A mixed collection has no single template, and is not side
63
+ # loaded.
64
+ module CollectionRenderer
65
+ private
86
66
 
87
- %i[css js].each do |k|
88
- options[k] = controller.instance_eval(&options[k]) if options[k].is_a?(Proc)
89
- end
67
+ # `args` is `path, template, layout, block`. `collection` is ActionView's CollectionIterator,
68
+ # which has `length` but no `empty?`.
69
+ def render_collection(collection, view, *args)
70
+ _path, template, layout, = args
71
+ return super unless collection.length.positive? && SideLoad.sideloadable?(view, template)
90
72
 
91
- Importer.sideload tpl_path, **options
73
+ SideLoad.sideload_templates(view, [layout, template]) { super }
92
74
  end
93
75
  end
94
76
  end
@@ -36,6 +36,20 @@ module Proscenium
36
36
 
37
37
  Proscenium::Manifest.load!
38
38
 
39
+ project = Proscenium::ContextMap.project_root
40
+ if (notice = Proscenium::ContextMap.adoption_notice(project))
41
+ Rails.logger.info "[Proscenium] #{notice}"
42
+ end
43
+ # Production finds stale contexts at assets:precompile, which refuses to compile. Elsewhere
44
+ # the first mapping generation starts here, so a change after boot, such as running
45
+ # `proscenium install`, starts the next one. No Go call: Puma may fork after boot.
46
+ unless Rails.env.production?
47
+ generation, = Proscenium::MappingGeneration.refresh(project)
48
+ if (stale = Proscenium::StaleContexts.message(project, generation))
49
+ Rails.logger.warn "[Proscenium] #{stale}"
50
+ end
51
+ end
52
+
39
53
  if config.proscenium.logging
40
54
  require 'proscenium/log_subscriber'
41
55
  Proscenium::LogSubscriber.attach_to :proscenium
@@ -78,8 +92,10 @@ module Proscenium
78
92
 
79
93
  initializer 'proscenium.monkey_patches' do
80
94
  ActiveSupport.on_load(:action_view) do
95
+ ActionView::Base.prepend Monkey::Base
81
96
  ActionView::TemplateRenderer.prepend Monkey::TemplateRenderer
82
97
  ActionView::PartialRenderer.prepend Monkey::PartialRenderer
98
+ ActionView::CollectionRenderer.prepend Monkey::CollectionRenderer
83
99
  end
84
100
  end
85
101
 
@@ -5,7 +5,8 @@ namespace :assets do
5
5
  task precompile: :environment do
6
6
  puts "\nPre-compiling assets..."
7
7
 
8
- raise 'Assets pre-compilation failed!' unless Proscenium::Builder.compile
8
+ # Raises Proscenium::Builder::CompileError, with esbuild's messages, when the build fails.
9
+ Proscenium::Builder.compile
9
10
 
10
11
  puts "\nAssets pre-compiled successfully."
11
12
 
@@ -11,21 +11,53 @@ module Proscenium
11
11
  # absolute file system path as an array. Only returns the resolved path if false (default).
12
12
  # @return [String, Array<String>]
13
13
  def self.resolve(path, as_array: false)
14
+ # Normalised before anything reads it, the guard and the cache key included. The bun test
15
+ # harness hands this Bun's own spelling of a module path, which on Windows is `D:\...`;
16
+ # matched against a slash-form Rails.root it fell through to the Go resolver and came back
17
+ # as the url path unchanged - a backslash path the harness then refused to build.
18
+ path = Utils.fs_path(path)
19
+
14
20
  if path.start_with?('./', '../')
15
21
  raise ArgumentError, '`path` must be an absolute file system or URL path'
16
22
  end
17
23
 
18
- resolved[path] ||= if (gem = BundledGems.paths.find { |_, v| path.start_with? "#{v}/" })
19
- vpath = path.sub(/^#{gem.last}/, "@rubygems/#{gem.first}")
20
- [Proscenium::Manifest[vpath], "/node_modules/#{vpath}", path]
21
- elsif path.start_with?("#{Rails.root}/")
22
- vpath = path.delete_prefix(Rails.root.to_s)
23
- [Proscenium::Manifest[vpath], vpath, path]
24
- else
25
- [Proscenium::Manifest[path], *Builder.resolve(path)]
26
- end
27
-
28
- as_array ? resolved[path] : resolved[path][0] || resolved[path][1]
24
+ # A new mapping generation forgets cached resolutions (#154): polled here too, so a path
25
+ # resolved before an install does not keep its old URL when no Builder is made after it.
26
+ generation
27
+
28
+ # Caches the manifest key, not its value, so a manifest loaded or reset later is still
29
+ # honoured. Every string is frozen: the URL path is handed back to callers, and in the
30
+ # Rails.root branch it is the key itself, so mutating it would redirect later lookups.
31
+ key, url_path, abs_path = resolved[path] ||= entry_for(path).map(&:-@).freeze
32
+ manifest_path = Proscenium::Manifest[key]
33
+
34
+ as_array ? [manifest_path, url_path, abs_path] : manifest_path || url_path
35
+ end
36
+
37
+ # The manifest key, URL path and absolute file system path for `path`. In the gem branch the
38
+ # key differs from the URL path, so it is kept rather than derived.
39
+ def self.entry_for(path)
40
+ if (vpath = BundledGems.virtual_path(path))
41
+ [vpath, "/node_modules/#{vpath}", path]
42
+ elsif path.start_with?("#{Rails.root}/")
43
+ vpath = path.delete_prefix(Rails.root.to_s)
44
+ [vpath, vpath, path]
45
+ else
46
+ [path, *Builder.resolve(path)]
47
+ end
48
+ end
49
+ private_class_method :entry_for
50
+
51
+ # The mapping generation builds and resolutions use now (#154). Development and test start a
52
+ # new one when the files the context map is built from change, and forget resolved paths with
53
+ # it; production keeps one for the process. Here rather than on Builder, which loads the Go
54
+ # library when first referenced, so resolving a path never needs it.
55
+ def self.generation
56
+ return 0 if Rails.env.production?
57
+
58
+ number, changed = MappingGeneration.refresh(ContextMap.project_root)
59
+ reset if changed
60
+ number
29
61
  end
30
62
 
31
63
  def self.reset
@@ -15,6 +15,13 @@ class Daemon {
15
15
  #buffer = "";
16
16
  #nextId = 1;
17
17
 
18
+ // Null while the connection is usable, and the error that ended it once it is not. A closed
19
+ // socket does not refuse a write - Bun's `write` returns -1 and throws nothing - so without this
20
+ // a request sent after the daemon died registered a promise nothing would ever settle, and every
21
+ // later module load hung, outside any per-test timeout. A reply that does not parse ends it too:
22
+ // the stream is no longer line-aligned, so nothing read from it after that can be trusted.
23
+ #failure = null;
24
+
18
25
  // Decodes across chunk boundaries. `chunk.toString()` decodes each chunk on its own, so a
19
26
  // multi-byte character split by the socket becomes U+FFFD on both sides - a module whose bytes
20
27
  // silently differ from the ones Rails served, which is the one thing this harness exists to
@@ -32,8 +39,8 @@ class Daemon {
32
39
  unix: socketPath,
33
40
  socket: {
34
41
  data: (_sock, chunk) => daemon.#onData(chunk),
35
- close: () => daemon.#rejectAll(new Error("proscenium daemon closed the connection")),
36
- error: (_sock, error) => daemon.#rejectAll(error),
42
+ close: () => daemon.#fail(new Error("proscenium daemon closed the connection")),
43
+ error: (_sock, error) => daemon.#fail(error),
37
44
  },
38
45
  });
39
46
  daemon = new Daemon(socket);
@@ -41,6 +48,8 @@ class Daemon {
41
48
  }
42
49
 
43
50
  send(op, args = {}) {
51
+ if (this.#failure) return Promise.reject(this.#failure);
52
+
44
53
  return new Promise((resolve, reject) => {
45
54
  const id = this.#nextId++;
46
55
  this.#pending.set(id, { resolve, reject });
@@ -65,7 +74,7 @@ class Daemon {
65
74
  try {
66
75
  reply = JSON.parse(line);
67
76
  } catch (error) {
68
- this.#rejectAll(error);
77
+ this.#fail(error);
69
78
  return;
70
79
  }
71
80
 
@@ -78,8 +87,11 @@ class Daemon {
78
87
  }
79
88
  }
80
89
 
81
- #rejectAll(error) {
82
- for (const { reject } of this.#pending.values()) reject(error);
90
+ // The first error wins, so a parse failure is still what callers see once the close that
91
+ // usually follows it arrives.
92
+ #fail(error) {
93
+ this.#failure ??= error;
94
+ for (const { reject } of this.#pending.values()) reject(this.#failure);
83
95
  this.#pending.clear();
84
96
  }
85
97
  }
@@ -123,8 +135,13 @@ async function spawnDaemon({ env, timeoutMs }) {
123
135
  // Must match Server::SOCKET_NAME. The daemon is handed the directory, not the path.
124
136
  const socketPath = join(socketDir, "d.sock");
125
137
 
138
+ // On Windows `bundle` is `bundle.bat`, and a batch file's arguments are re-parsed by cmd.exe.
139
+ // Bun 1.4 refuses that outright for an argument holding a cmd.exe special character, which the
140
+ // Ruby code below always does ("The argument 'args[6]' contains a cmd.exe special character").
141
+ // `ruby -S bundle` runs the same `bundle` script from PATH with ruby.exe, so no batch file and
142
+ // no cmd.exe are involved and the argument arrives exactly as written.
126
143
  const command = [
127
- "bundle",
144
+ ...(process.platform === "win32" ? ["ruby", "-S", "bundle"] : ["bundle"]),
128
145
  "exec",
129
146
  "rails",
130
147
  "runner",
@@ -244,3 +261,6 @@ export async function register(options = {}) {
244
261
  }
245
262
 
246
263
  export default register;
264
+
265
+ // Exported for its tests, which drive it against a fake daemon.
266
+ export { Daemon };