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
data/exe/proscenium ADDED
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Run as `bundle exec proscenium`. Loads the CLI alone: never `proscenium` itself, Rails, FFI or
5
+ # the engine library (see lib/proscenium/cli.rb).
6
+ # When the CLI started loading, for the timings install reports (C40).
7
+ PROSCENIUM_STARTED = Process.clock_gettime(Process::CLOCK_MONOTONIC)
8
+
9
+ # Every file the CLI reads (package.json, contexts, pnpm-workspace.yaml) is UTF-8, whatever the
10
+ # locale: under LANG=C, as minimal Docker and CI images run, Ruby would read them as US-ASCII.
11
+ # Ruby warns of the setting, which is meant for this process alone.
12
+ verbose = $VERBOSE
13
+ $VERBOSE = nil
14
+ Encoding.default_external = Encoding::UTF_8
15
+ $VERBOSE = verbose
16
+
17
+ require 'proscenium/cli'
18
+
19
+ exit Proscenium::CLI.start(ARGV)
data/exe/psm ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # `bundle exec psm`: a shorter name for `bundle exec proscenium`, which it runs as is.
5
+ load File.expand_path('proscenium', __dir__)
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators/base'
4
+
5
+ module Proscenium
6
+ module Generators
7
+ # Sets up `bun test` so it can import the same JS, TS, JSX and CSS the app serves.
8
+ #
9
+ # rails generate proscenium:bun
10
+ #
11
+ # Safe to run more than once: the preload is created if missing, and bunfig.toml is merged
12
+ # rather than overwritten, because an app may already have one with its own preloads.
13
+ class BunGenerator < Rails::Generators::Base
14
+ PRELOAD_PATH = 'test/proscenium.preload.js'
15
+ PRELOAD_ENTRY = './test/proscenium.preload.js'
16
+ BUNFIG_PATH = 'bunfig.toml'
17
+
18
+ source_root File.expand_path('templates', __dir__)
19
+
20
+ desc 'Configure bun test to run against your Proscenium-bundled JavaScript.'
21
+
22
+ def create_preload
23
+ template 'proscenium.preload.js', PRELOAD_PATH
24
+ end
25
+
26
+ def configure_bunfig
27
+ return create_file(BUNFIG_PATH, default_bunfig) unless File.exist?(bunfig_full_path)
28
+
29
+ contents = File.read(bunfig_full_path)
30
+
31
+ if already_preloaded?(contents)
32
+ say_status :identical, BUNFIG_PATH, :blue
33
+ elsif (updated = with_preload(contents))
34
+ File.write(bunfig_full_path, updated)
35
+ say_status :update, BUNFIG_PATH, :green
36
+ else
37
+ say_manual_step
38
+ end
39
+ end
40
+
41
+ def report
42
+ say ''
43
+ say 'Now write a test that imports your app code, and run `bun test`:'
44
+ say ''
45
+ say ' // test/js/button.test.jsx'
46
+ say ' import { expect, test } from "bun:test";'
47
+ say ' import Button from "/app/components/button.jsx";'
48
+ say ''
49
+ end
50
+
51
+ private
52
+
53
+ # Only a real entry counts. The bare string can also appear in a commented-out line, and
54
+ # reporting "identical" then would silently do nothing for someone who had disabled it.
55
+ def already_preloaded?(contents)
56
+ test_table(contents).to_s.match?(/^[^#\n]*["']#{Regexp.escape(PRELOAD_ENTRY)}["']/o)
57
+ end
58
+
59
+ # Returns the whole file with our entry added inside the `[test]` table, or nil when that
60
+ # cannot be done without risking the file.
61
+ #
62
+ # Editing TOML with a regex is how this went wrong before, so the rules are narrow and each
63
+ # refusal falls through to printing the two lines for the user to add:
64
+ #
65
+ # - a `preload` key outside `[test]` is left alone. It belongs to `bun run`, and appending
66
+ # to it put the test preload somewhere `bun test` never reads.
67
+ # - `[test]` already present without a `preload` key gets the key inserted into it. The
68
+ # previous version appended a second `[test]` table, which is invalid TOML.
69
+ # - an existing `[test] preload` array is extended in place, respecting a trailing comma.
70
+ # Appending `, "entry"` after one produced `,\n, "entry"`, also invalid TOML.
71
+ def with_preload(contents)
72
+ table = test_table(contents)
73
+
74
+ return "#{contents.sub(/\n*\z/, "\n")}\n#{default_bunfig}" if table.nil?
75
+
76
+ if (array = table[/^[^#\n]*\bpreload\s*=\s*\[.*?\]/m])
77
+ extended = extend_array(array)
78
+ return nil if extended.nil?
79
+
80
+ # Substituted inside the table, then the table inside the document. `contents.sub(array)`
81
+ # replaces the FIRST occurrence of that text anywhere - so a root-level `preload` holding
82
+ # the same entries as `[test]`'s got edited instead, which put the harness in `bun run`,
83
+ # left `bun test` without it, and reported success.
84
+ return contents.sub(table) { table.sub(array) { extended } }
85
+ end
86
+
87
+ # `[test]` exists but has no preload key: put one directly under its header.
88
+ header = table[/\A\[test\][^\n]*\n/]
89
+ return nil if header.nil?
90
+
91
+ contents.sub(table) { table.sub(header, %(#{header}preload = ["#{PRELOAD_ENTRY}"]\n)) }
92
+ end
93
+
94
+ # The `[test]` table's text, from its header to the next table header or end of file. Nil
95
+ # when the file has no `[test]` table.
96
+ # The header line may end at EOF rather than a newline. Requiring the newline made a bunfig
97
+ # whose last line is `[test]` look like it had no `[test]` table at all, and the nil branch
98
+ # above then appended a second one - the invalid-TOML shape this rewrite exists to avoid.
99
+ # Matching it here instead leaves `with_preload` unable to find a header to insert under, so
100
+ # it refuses and prints the two lines for the user to add.
101
+ def test_table(contents)
102
+ contents[/^\[test\][^\n]*(?:\n|\z).*?(?=^\[|\z)/m]
103
+ end
104
+
105
+ # Adds our entry to an existing array, keeping the file's own shape. A trailing comma is
106
+ # respected rather than doubled - appending ", entry" after one is what produced invalid
107
+ # TOML before. Returns nil when the array's shape is one this cannot edit safely.
108
+ def extend_array(array)
109
+ inner = array[/\[(.*)\]/m, 1]
110
+ entry = %("#{PRELOAD_ENTRY}")
111
+
112
+ return array.sub(/\[\s*\]/m, "[#{entry}]") if inner.strip.empty?
113
+
114
+ # A comment after the last entry swallows the separator: the inserted `, ` lands inside
115
+ # `# DOM shim` and the two entries end up with no comma between them - valid input, invalid
116
+ # output. Putting the comma before the comment means parsing TOML comments, which is the
117
+ # approximation this file keeps getting punished for. Refuse and let the user do it.
118
+ # A comment after the last entry swallows the separator: the inserted `, ` lands inside
119
+ # `# DOM shim` and the two entries end up with no comma between them - valid input, invalid
120
+ # output. Putting the comma before the comment means parsing TOML comments, which is the
121
+ # approximation this file keeps getting punished for. Refuse and let the user do it.
122
+ return nil if inner.match?(/(?<!["'])#/)
123
+
124
+ separator = inner.rstrip.end_with?(',') ? '' : ', '
125
+ array.sub(/(\s*)\]\z/) { "#{separator}#{::Regexp.last_match(1)}#{entry}]" }
126
+ end
127
+
128
+ def say_manual_step
129
+ say_status :skip, BUNFIG_PATH, :yellow
130
+ say ''
131
+ say " Could not edit #{BUNFIG_PATH} safely. Add this to it by hand:"
132
+ say ''
133
+ say ' [test]'
134
+ say %( preload = ["#{PRELOAD_ENTRY}"])
135
+ say ''
136
+ end
137
+
138
+ def bunfig_full_path
139
+ File.expand_path(BUNFIG_PATH, destination_root)
140
+ end
141
+
142
+ def default_bunfig
143
+ <<~TOML
144
+ [test]
145
+ preload = ["#{PRELOAD_ENTRY}"]
146
+ TOML
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,61 @@
1
+ // Registers Proscenium's Bun plugin, so `bun test` can import the same JS, TS, JSX and CSS your
2
+ // Rails app serves. Generated by `rails generate proscenium:bun`.
3
+ //
4
+ // Everything of substance lives in the gem, so this file should not need to change when
5
+ // Proscenium does.
6
+ //
7
+ // Keep this LAST in bunfig.toml's `preload` list. The plugin claims every uncached JS module, so
8
+ // anything that has to come from node_modules - a DOM shim, a testing library - must already be
9
+ // imported and cached by an earlier preload, or it gets fetched from your Rails app instead.
10
+
11
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ // Written to a file rather than read off a pipe, because a pipe cannot be relied on from here.
15
+ // Once happy-dom's GlobalRegistrator has run and a DOM-touching package has been imported - which
16
+ // is the normal state of a preload that comes before this one - every `Bun.spawn`/`Bun.spawnSync`
17
+ // in the process returns a zero-length stdout: exit status 0, empty stderr, no bytes, for any
18
+ // command at all.
19
+ //
20
+ // The file goes in a private directory under the app's own tmp/, beside where the daemon
21
+ // materialises `.rjs` modules, rather than in the shared system temp dir. `mkdtemp` is what makes
22
+ // it safe either way - the name stops being guessable and the create is exclusive by construction,
23
+ // so nobody else on the machine can pre-place a file here and break or redirect the run - but an
24
+ // app-private parent means there is no one else in the directory to begin with.
25
+ mkdirSync("tmp/proscenium", { recursive: true });
26
+ const gemDirDir = mkdtempSync(join("tmp/proscenium", "gem-dir-"));
27
+ const gemDirFile = join(gemDirDir, "gem-dir");
28
+
29
+ let gemDir = "";
30
+ try {
31
+ Bun.spawnSync(["bundle", "exec", "ruby", "-e", "print Gem.loaded_specs['proscenium'].gem_dir"], {
32
+ stdout: Bun.file(gemDirFile),
33
+ stderr: "inherit",
34
+ });
35
+
36
+ gemDir = (await Bun.file(gemDirFile).text()).trim();
37
+ } catch (error) {
38
+ // `Bun.spawnSync` throws rather than returning when the command cannot be run at all - no
39
+ // bundler on PATH, or not a Ruby project - and then the output file was never created, so the
40
+ // read throws too. Both land here so the message below is the one thing the developer sees.
41
+ gemDir = "";
42
+ if (error?.code !== "ENOENT") throw error;
43
+ } finally {
44
+ rmSync(gemDirDir, { recursive: true, force: true });
45
+ }
46
+
47
+ // Checked for being a directory, not merely non-empty: bundler writes some of its own
48
+ // diagnostics to stdout ("Could not locate Gemfile", for one), which would otherwise sail past a
49
+ // truthiness check and fail later as an unreadable module path.
50
+ if (!gemDir || !existsSync(gemDir) || !statSync(gemDir).isDirectory()) {
51
+ throw new Error(
52
+ "could not locate the proscenium gem. `bundle exec ruby -e \"print " +
53
+ "Gem.loaded_specs['proscenium'].gem_dir\"` did not name a directory" +
54
+ (gemDir ? ` - it produced ${JSON.stringify(gemDir.slice(0, 200))}` : " - it produced nothing") +
55
+ ". Is this a Rails app with proscenium in its Gemfile, and is bundler on PATH?",
56
+ );
57
+ }
58
+
59
+ const { register } = await import(`${gemDir}/lib/proscenium/runtime/bootstrap.js`);
60
+
61
+ await register();
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Proscenium
6
+ # What the app's own package.json says that the context map needs (#154). Like ContextMap, which
7
+ # it reads JSON through, it loads nothing the `proscenium` CLI may not.
8
+ module AppPackage
9
+ LOCAL_SPECS = %w[link: file: workspace: portal:].freeze
10
+
11
+ module_function
12
+
13
+ # The names of the app's own `link:`, `file:` and workspace dependencies, peers included, as
14
+ # pnpm installs those too, and in a Bun app any workspace member it depends on by version,
15
+ # which Bun links. They keep their link paths once dependency contexts make other packages
16
+ # real-path.
17
+ def local_packages(root)
18
+ package = read(root) or return []
19
+
20
+ members = bun?(root, package) ? workspace_members(root, package['workspaces']) : []
21
+ %w[dependencies devDependencies optionalDependencies peerDependencies].flat_map do |field|
22
+ deps = package[field]
23
+ next [] unless deps.is_a?(Hash)
24
+
25
+ deps.select { |name, spec| spec.to_s.start_with?(*LOCAL_SPECS) || members.include?(name) }
26
+ .keys
27
+ end.uniq.sort
28
+ end
29
+
30
+ # Whether Bun installs the app: packageManager names it, or else its lockfile is there.
31
+ def bun?(root, package)
32
+ named = package['packageManager']
33
+ return named.split('@').first == 'bun' if named.is_a?(String)
34
+
35
+ %w[bun.lock bun.lockb].any? { File.exist?(File.join(root, it)) }
36
+ end
37
+
38
+ # The names of the packages `workspaces` (an array, or an object's `packages`) takes in, less
39
+ # those a `!` pattern leaves out, read as Bun's glob does: braces and `**` included.
40
+ def workspace_members(root, workspaces)
41
+ patterns = Array(workspaces.is_a?(Hash) ? workspaces['packages'] : workspaces).grep(String)
42
+ excluded, included = patterns.map { it.delete_prefix('./').chomp('/') }
43
+ .partition { it.start_with?('!') }
44
+ dirs = included.flat_map { Dir.glob(it, File::FNM_EXTGLOB, base: root) }.uniq
45
+ dirs.reject { |dir| excluded.any? { File.fnmatch?(it[1..], dir, File::FNM_EXTGLOB) } }
46
+ .filter_map { read(File.join(root, it))&.dig('name') }
47
+ end
48
+
49
+ # Whether package.json's text, for a file JSON.parse refuses, registers the contexts in its
50
+ # `workspaces`, decoding JSON's `\/` and `\uXXXX`
51
+ # escapes in the key and the pattern, as Bun does.
52
+ def workspaces?(text) = unescape(text).match?(WORKSPACES)
53
+
54
+ # JSON text with its `\uXXXX` and `\/` escapes decoded, as Bun reads them, for matching a file
55
+ # JSON.parse refuses.
56
+ # A surrogate pair is one character; a lone half, which JSON allows, becomes U+FFFD rather
57
+ # than raising.
58
+ def unescape(text)
59
+ text.gsub(ESCAPE) do
60
+ high, low, single = ::Regexp.last_match.captures
61
+ if high then (0x10000 + ((high.hex - 0xD800) << 10) + (low.hex - 0xDC00)).chr(Encoding::UTF_8)
62
+ elsif single then SURROGATES.cover?(single.hex) ? "\uFFFD" : single.hex.chr(Encoding::UTF_8)
63
+ else '/'
64
+ end
65
+ end
66
+ end
67
+
68
+ ESCAPE = %r{\\u([dD][89abAB]\h{2})\\u([dD][c-fC-F]\h{2})|\\u(\h{4})|\\/}
69
+ SURROGATES = (0xD800..0xDFFF)
70
+
71
+ # The pattern inside package.json's `workspaces`, an array or an object's `packages`.
72
+ WORKSPACES = /"workspaces"\s*:\s*(?:\{[^}]*"packages"\s*:\s*)?\[[^\]]*
73
+ "\.proscenium\/packages\/\*"/x
74
+
75
+ # The manager `packageManager` names, or nil.
76
+ def package_manager(root)
77
+ field = read(root)&.dig('packageManager')
78
+ field.split('@').first if field.is_a?(String)
79
+ end
80
+
81
+ # The app's package.json as an object, or nil when it is missing or not one.
82
+ def read(root)
83
+ path = File.join(root, 'package.json')
84
+ package = File.exist?(path) && ContextMap.read_json(path)
85
+ package if package.is_a?(Hash)
86
+ rescue JSON::ParserError
87
+ nil
88
+ end
89
+ end
90
+ end