proscenium 0.26.0-aarch64-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,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+
6
+ module Proscenium
7
+ # A participating gem's dependency context (#154): its package.json through the
8
+ # `dependency-context-v1` projection, which keeps installation metadata only and is committed at
9
+ # `.proscenium/packages/<gem>/package.json`.
10
+ #
11
+ # The one owner of the projection and its hash, shared by the CLI and the engine, so it must not
12
+ # load ActiveSupport or anything else the CLI may not.
13
+ module DependencyContext
14
+ PROJECTION = 'dependency-context-v1'
15
+
16
+ # The manifest fields a context keeps, in the order it writes them. Everything else (scripts,
17
+ # devDependencies, version, entry points, workspaces) is left in the gem's own package.json.
18
+ FIELDS = %w[dependencies peerDependencies peerDependenciesMeta optionalDependencies engines os
19
+ cpu libc].freeze
20
+
21
+ module_function
22
+
23
+ def name_for(gem) = "@rubygems/#{gem}"
24
+
25
+ def description_for(gem)
26
+ "Generated by Proscenium from the #{gem} gem. Do not edit; " \
27
+ 'run bundle exec proscenium install.'
28
+ end
29
+
30
+ # The context for `gem` from its parsed `manifest`. An empty field is left out, as an absent
31
+ # one is: the two install the same, so they hash the same.
32
+ def project(gem, manifest)
33
+ fields = FIELDS.each_with_object({}) do |key, kept|
34
+ value = manifest[key]
35
+ kept[key] = value unless value.nil? || (value.respond_to?(:empty?) && value.empty?)
36
+ end
37
+
38
+ { 'name' => name_for(gem), 'private' => true, 'description' => description_for(gem),
39
+ **fields,
40
+ 'proscenium' => { 'projection' => PROJECTION, 'projectionSha256' => digest(gem, fields) } }
41
+ end
42
+
43
+ # The bytes committed for a context: fixed key order, two-space indent, a trailing newline, and
44
+ # nothing that varies by host or time.
45
+ def to_json(context) = "#{JSON.pretty_generate(context)}\n"
46
+
47
+ # The projection hash. It hashes a fixed-field encoding of the projected fields, not JSON, so
48
+ # it does not move with the json gem's output, key order, whitespace or escaping, and an edit
49
+ # to anything the projection drops leaves it alone.
50
+ def digest(gem, fields)
51
+ Digest::SHA256.hexdigest("#{PROJECTION}\n#{encode(name_for(gem))}#{encode(fields)}")
52
+ end
53
+
54
+ # A self-delimiting encoding: a type tag, a length where there is one, then the value. Hash
55
+ # keys are sorted by their bytes, so key order in the manifest does not matter; array order
56
+ # does, as it does to npm.
57
+ def encode(value)
58
+ case value
59
+ when String then "s#{value.bytesize}:#{value.b}"
60
+ when true then 't'
61
+ when false then 'f'
62
+ when nil then 'n'
63
+ when Integer then "i#{value};"
64
+ when Float then "d#{value};"
65
+ when Array then "a#{value.size}:#{value.map { encode(it) }.join}"
66
+ when Hash
67
+ pairs = value.sort_by { |key, _| key.to_s.b }
68
+ "h#{value.size}:#{pairs.map { |key, val| encode(key.to_s) + encode(val) }.join}"
69
+ else
70
+ raise ArgumentError, "cannot encode #{value.class} in a dependency context"
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proscenium
4
+ NotIncludedError = Class.new(Error) # rubocop:disable Style/EmptyClassDefinition
5
+
6
+ module EnsureLoaded
7
+ def self.included(child)
8
+ child.class_eval do
9
+ append_after_action do
10
+ if request.format.html? && !response.redirect? && Importer.imported?
11
+ msg = <<-TEXT.squish
12
+ There are side loaded and imported assets to be included, but they have not been
13
+ included in the page. Did you forget to add the `#include_assets` helper in your
14
+ views? These assets were imported but not included:
15
+ #{Importer.imported.keys.to_sentence}
16
+ TEXT
17
+
18
+ if Proscenium.config.ensure_loaded == :log
19
+ Rails.logger.warn do
20
+ "#{ActiveSupport::LogSubscriber.new.send(:color, ' [Proscenium]', nil,
21
+ bold: true)} #{msg}"
22
+ end
23
+ elsif Proscenium.config.ensure_loaded == :raise
24
+ raise NotIncludedError, msg
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proscenium
4
+ # The base of every error Proscenium raises.
5
+ #
6
+ # In a file of its own so that lib/proscenium/builder.rb can load without the rest of the gem:
7
+ # the release workflow loads the compiled library from an installed gem to prove it works, with
8
+ # no Rails app and no Rails, and builder.rb subclasses this at class-definition time.
9
+ class Error < StandardError; end
10
+ end
Binary file
@@ -0,0 +1,113 @@
1
+ /* Code generated by cmd/cgo; DO NOT EDIT. */
2
+
3
+ /* package joelmoss/proscenium */
4
+
5
+
6
+ #line 1 "cgo-builtin-export-prolog"
7
+
8
+ #include <stddef.h>
9
+
10
+ #ifndef GO_CGO_EXPORT_PROLOGUE_H
11
+ #define GO_CGO_EXPORT_PROLOGUE_H
12
+
13
+ #ifndef GO_CGO_GOSTRING_TYPEDEF
14
+ typedef struct { const char *p; ptrdiff_t n; } _GoString_;
15
+ extern size_t _GoStringLen(_GoString_ s);
16
+ extern const char *_GoStringPtr(_GoString_ s);
17
+ #endif
18
+
19
+ #endif
20
+
21
+ /* Start of preamble from import "C" comments. */
22
+
23
+
24
+ #line 3 "main.go"
25
+
26
+ #include <stdlib.h>
27
+
28
+ struct Result {
29
+ int success;
30
+ char* response;
31
+ char* contentHash;
32
+ };
33
+ struct ResolveResult {
34
+ int success;
35
+ char* urlPath;
36
+ char* absPath;
37
+ };
38
+ struct CompileResult {
39
+ int success;
40
+ char* messages;
41
+ };
42
+
43
+ #line 1 "cgo-generated-wrapper"
44
+
45
+
46
+ /* End of preamble from import "C" comments. */
47
+
48
+
49
+ /* Start of boilerplate cgo prologue. */
50
+ #line 1 "cgo-gcc-export-header-prolog"
51
+
52
+ #ifndef GO_CGO_PROLOGUE_H
53
+ #define GO_CGO_PROLOGUE_H
54
+
55
+ typedef signed char GoInt8;
56
+ typedef unsigned char GoUint8;
57
+ typedef short GoInt16;
58
+ typedef unsigned short GoUint16;
59
+ typedef int GoInt32;
60
+ typedef unsigned int GoUint32;
61
+ typedef long long GoInt64;
62
+ typedef unsigned long long GoUint64;
63
+ typedef GoInt64 GoInt;
64
+ typedef GoUint64 GoUint;
65
+ typedef size_t GoUintptr;
66
+ typedef float GoFloat32;
67
+ typedef double GoFloat64;
68
+ #ifdef _MSC_VER
69
+ #if !defined(__cplusplus) || _MSVC_LANG <= 201402L
70
+ #include <complex.h>
71
+ typedef _Fcomplex GoComplex64;
72
+ typedef _Dcomplex GoComplex128;
73
+ #else
74
+ #include <complex>
75
+ typedef std::complex<float> GoComplex64;
76
+ typedef std::complex<double> GoComplex128;
77
+ #endif
78
+ #else
79
+ typedef float _Complex GoComplex64;
80
+ typedef double _Complex GoComplex128;
81
+ #endif
82
+
83
+ /*
84
+ static assertion to make sure the file is being used on architecture
85
+ at least with matching size of GoInt.
86
+ */
87
+ typedef char _check_for_64_bit_pointer_matching_GoInt[sizeof(void*)==64/8 ? 1:-1];
88
+
89
+ #ifndef GO_CGO_GOSTRING_TYPEDEF
90
+ typedef _GoString_ GoString;
91
+ #endif
92
+ typedef void *GoMap;
93
+ typedef void *GoChan;
94
+ typedef struct { void *t; void *v; } GoInterface;
95
+ typedef struct { void *data; GoInt len; GoInt cap; } GoSlice;
96
+
97
+ #endif
98
+
99
+ /* End of boilerplate cgo prologue. */
100
+
101
+ #ifdef __cplusplus
102
+ extern "C" {
103
+ #endif
104
+
105
+ extern void reset_config(void);
106
+ extern void free_cstr(char* ptr);
107
+ extern struct Result build_to_string(char* filePath, char* configJson);
108
+ extern struct ResolveResult resolve(char* filePath, char* configJson);
109
+ extern struct CompileResult compile(char* configJson);
110
+
111
+ #ifdef __cplusplus
112
+ }
113
+ #endif
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proscenium
4
+ module Helper
5
+ # Sets the side load options of the template being rendered, for this render only. They are
6
+ # kept on the view, which lives for one request, keyed by template, because the template
7
+ # object itself is cached and shared by every request. `SideLoad.sideload_templates` scopes
8
+ # each value to the render that set it.
9
+ #
10
+ # Call it outside any `cache` block: a cache hit skips the block, and this call with it.
11
+ def sideload_assets(value)
12
+ # ActionView does not push a partial rendered with a block onto `@current_template`, so while
13
+ # its body renders, `@current_template` is still the calling template.
14
+ identifier = (@proscenium_block_partial || @current_template).identifier
15
+
16
+ if value.nil?
17
+ proscenium_sideload_assets_options.delete identifier
18
+ else
19
+ proscenium_sideload_assets_options[identifier] = value
20
+ end
21
+ end
22
+
23
+ # Renders a partial given `block`, and yields the block to render it with. While the partial's
24
+ # body renders, `sideload_assets` applies to the partial. While the block runs, wherever it is
25
+ # run from, it applies to the template that passed the block. Any template rendered from
26
+ # either pushes itself onto `@current_template`, and `Monkey::Base` clears this while it does.
27
+ def proscenium_render_block_partial(partial, block)
28
+ previous = @proscenium_block_partial
29
+ calling_template = previous || @current_template
30
+ @proscenium_block_partial = partial
31
+
32
+ yield(proc do |*args|
33
+ current = @proscenium_block_partial
34
+ @proscenium_block_partial = calling_template
35
+ block.call(*args)
36
+ ensure
37
+ @proscenium_block_partial = current
38
+ end)
39
+ ensure
40
+ @proscenium_block_partial = previous
41
+ end
42
+
43
+ # @return [Hash] the `sideload_assets` value of each template in this render, by identifier.
44
+ def proscenium_sideload_assets_options
45
+ @proscenium_sideload_assets_options ||= {}
46
+ end
47
+
48
+ def compute_asset_path(path, options = {})
49
+ if %i[javascript stylesheet].include?(options[:type])
50
+ return Proscenium::Manifest[path] || "/#{path}"
51
+ end
52
+
53
+ super
54
+ end
55
+
56
+ # Accepts one or more CSS class names, and transforms them into CSS module names.
57
+ #
58
+ # @see CssModule::Transformer#class_names
59
+ # @param names [String,Symbol,nil,false,Array<String,Symbol,nil,false>]
60
+ # @param path [Pathname] the path to the CSS module file to use for the transformation.
61
+ # @return [String] the transformed CSS module names concatenated as a string.
62
+ def css_module(*names, path: nil)
63
+ path ||= Pathname.new(@lookup_context.find(@virtual_path).identifier).sub_ext('')
64
+ CssModule::Transformer.new(path).class_names(*names, require_prefix: false).join(' ')
65
+ end
66
+
67
+ def include_assets
68
+ include_stylesheets + include_javascripts
69
+ end
70
+
71
+ def include_stylesheets
72
+ SideLoad::CSS_COMMENT.html_safe
73
+ end
74
+
75
+ # Includes all javascripts that have been imported and side loaded.
76
+ #
77
+ # @return [String] the HTML tags for the javascripts.
78
+ def include_javascripts
79
+ SideLoad::JS_COMMENT.html_safe
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/current_attributes'
4
+ require 'concurrent/map'
5
+
6
+ module Proscenium
7
+ class Importer < ActiveSupport::CurrentAttributes
8
+ JS_EXTENSIONS = %w[.tsx .ts .jsx .js].freeze
9
+ CSS_EXTENSIONS = %w[.module.css .css].freeze
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
+
17
+ # Holds the JS and CSS files to include in the current request.
18
+ #
19
+ # Example:
20
+ # {
21
+ # '/path/to/input/file.js': {
22
+ # output: '/path/to/compiled/file.js',
23
+ # **options
24
+ # }
25
+ # }
26
+ attribute :imported
27
+
28
+ class << self
29
+ # Import the given `filepath`. This is idempotent - it will never include duplicates.
30
+ #
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.
34
+ # @return [String|nil] the digest of the imported file path if a css module (*.module.css).
35
+ def import(filepath = nil, sideloaded: false, **)
36
+ self.imported ||= {}
37
+
38
+ if filepath.end_with?('.module.css')
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:)
48
+
49
+ transformed_path = ''
50
+ # Mirrors ConfigT#ShouldMinify - the suffix exists whenever identifiers are not
51
+ # minified, and a class name the stylesheet does not define is worse than a long one.
52
+ if Proscenium.config.debug || !Rails.env.production?
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
65
+ end
66
+
67
+ "#{digest}#{transformed_path}"
68
+ else
69
+ Array(Resolver.resolve(filepath)).each { |fp| store(fp, sideloaded, **) }
70
+ end
71
+ end
72
+
73
+ # Sideloads JS and CSS assets for the given Ruby filepath.
74
+ #
75
+ # Any files with the same base name and matching a supported extension will be sideloaded.
76
+ # Only one JS and one CSS file will be sideloaded, with the first match used in the following
77
+ # order:
78
+ # - JS extensions: .tsx, .ts, .jsx, and .js.
79
+ # - CSS extensions: .css.module, and .css.
80
+ #
81
+ # Example:
82
+ # - `app/views/layouts/application.rb`
83
+ # - `app/views/layouts/application.css`
84
+ # - `app/views/layouts/application.js`
85
+ # - `app/views/layouts/application.tsx`
86
+ #
87
+ # A request to sideload `app/views/layouts/application.rb` will result in `application.css`
88
+ # and `application.tsx` being sideloaded. `application.js` will not be sideloaded because the
89
+ # `.tsx` extension is matched first.
90
+ #
91
+ # @param filepath [Pathname] Absolute file system path of the Ruby file to sideload.
92
+ # @param options [Hash] Options to pass to `import`.
93
+ def sideload(filepath, **options)
94
+ return if !Proscenium.config.side_load || (options[:js] == false && options[:css] == false)
95
+
96
+ sideload_js(filepath, **options) unless options[:js] == false
97
+ sideload_css(filepath, **options) unless options[:css] == false
98
+ end
99
+
100
+ def sideload_js(filepath, **)
101
+ _sideload(filepath, JS_EXTENSIONS, **)
102
+ end
103
+
104
+ def sideload_css(filepath, **)
105
+ _sideload(filepath, ['.css'], **)
106
+ end
107
+
108
+ def sideload_css_module(filepath, **)
109
+ _sideload(filepath, ['.module.css'], **)
110
+ end
111
+
112
+ # @param filepath [Pathname] Absolute file system path of the Ruby file to sideload.
113
+ # @param extensions [Array<String>] Supported file extensions to sideload.
114
+ # @raise [ArgumentError] if `filepath` is not an absolute file system path.
115
+ private def _sideload(filepath, extensions, **) # rubocop:disable Style/AccessModifierDeclarations
116
+ return unless Proscenium.config.side_load
117
+
118
+ if !filepath.is_a?(Pathname) || !filepath.absolute?
119
+ raise ArgumentError, "`filepath` (#{filepath}) must be a `Pathname`, and an absolute path"
120
+ end
121
+
122
+ # Ensures extensions with more than one dot are handled correctly.
123
+ filepath = filepath.sub_ext('').sub_ext('')
124
+
125
+ extensions.find do |x|
126
+ next unless (fp = filepath.sub_ext(x)).exist?
127
+
128
+ import(fp.to_s, sideloaded: filepath, **)
129
+ end
130
+ end
131
+
132
+ def each_stylesheet(delete: false)
133
+ return if imported.blank?
134
+
135
+ blk = proc do |key, options|
136
+ if key.end_with?(*CSS_EXTENSIONS)
137
+ yield(key, options)
138
+ true
139
+ end
140
+ end
141
+
142
+ delete ? imported.delete_if(&blk) : imported.each(&blk)
143
+ end
144
+
145
+ def each_javascript(delete: false)
146
+ return if imported.blank?
147
+
148
+ blk = proc do |key, options|
149
+ if key.end_with?(*JS_EXTENSIONS)
150
+ yield(key, options)
151
+ true
152
+ end
153
+ end
154
+ delete ? imported.delete_if(&blk) : imported.each(&blk)
155
+ end
156
+
157
+ def css_imported?
158
+ imported&.keys&.any? { |x| x.end_with?(*CSS_EXTENSIONS) }
159
+ end
160
+
161
+ def js_imported?
162
+ imported&.keys&.any? { |x| x.end_with?(*JS_EXTENSIONS) }
163
+ end
164
+
165
+ def imported?(filepath = nil)
166
+ filepath ? imported&.key?(filepath) : !imported.blank?
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
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/log_subscriber'
4
+
5
+ module Proscenium
6
+ class LogSubscriber < ActiveSupport::LogSubscriber
7
+ def sideload(event)
8
+ path = event.payload[:identifier]
9
+ sideloaded = event.payload[:sideloaded]
10
+ sideloaded = sideloaded.relative_path_from(Rails.root) if sideloaded.is_a?(Pathname)
11
+
12
+ info do
13
+ msg = " #{color('[Proscenium]', nil, bold: true)} Sideloading #{path}"
14
+ sideloaded.is_a?(Pathname) ? msg << " from #{sideloaded}" : msg
15
+ end
16
+ end
17
+
18
+ def build(event)
19
+ path = event.payload[:identifier]
20
+ path = CGI.unescape(path) if path.start_with?(/https?%3A%2F%2F/)
21
+
22
+ info do
23
+ message = " #{color('[Proscenium]', nil, bold: true)} Building /#{path}"
24
+ message << " (Duration: #{event.duration.round(1)}ms | Allocations: #{event.allocations})"
25
+ end
26
+ end
27
+
28
+ def resolve(event)
29
+ path = event.payload[:identifier]
30
+ path = CGI.unescape(path) if path.start_with?(/https?%3A%2F%2F/)
31
+
32
+ info do
33
+ message = " #{color('[Proscenium]', nil, bold: true)} Resolving #{path}"
34
+ message << " (Duration: #{event.duration.round(1)}ms | Allocations: #{event.allocations})"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proscenium
4
+ module Manifest
5
+ mattr_accessor :manifest, default: {}
6
+
7
+ module_function
8
+
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.
11
+ def load!
12
+ public_path = Rails.configuration.paths['public'].first
13
+ entries = {}
14
+
15
+ if Proscenium.config.manifest_path.exist?
16
+ JSON.parse(Proscenium.config.manifest_path.read)['outputs'].each do |outpath, details|
17
+ next if !details.key?('entryPoint')
18
+
19
+ outpath = fs_path(outpath).delete_prefix "#{public_path}/"
20
+
21
+ ep = fs_path(details['entryPoint'])
22
+ ep = BundledGems.virtual_path(ep) || ep.delete_prefix(Rails.root.to_s)
23
+
24
+ entries[ep] = [
25
+ "/#{outpath}",
26
+ details['cssBundle'] && fs_path(details['cssBundle']).delete_prefix(public_path)
27
+ ].compact
28
+ end
29
+ end
30
+
31
+ self.manifest = entries
32
+ end
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!`.
42
+ def reset!
43
+ self.manifest = {}
44
+ end
45
+
46
+ def [](key) = manifest[key]
47
+ end
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