proscenium 0.24.2-x86_64-linux → 0.25.0-x86_64-linux

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0a33dbc6045d6adb9bedfe29baec84bb0d6f582196ecafc664206b396d0ece2a
4
- data.tar.gz: 4a54f176e92d4a8068d7793c2478877dc21092d57e59a902d2fd54a20eefb2d3
3
+ metadata.gz: 53a0eb7404c00e8bc3c60aaa156ad30832aa8844a2909483c5f2c63a67a683fe
4
+ data.tar.gz: e75ff41a520488a59f9676d004411b0253ef2041f72c25ca56601858ef9642c8
5
5
  SHA512:
6
- metadata.gz: db95c41e72d8dd0d8d84017b5d827020a945b8b7b596e1e469531414279b63a26ad70f89d86bd39e8883dbe267eb94e966c259c6a9954b2ace9d0c9fe9261adc
7
- data.tar.gz: 318ac8b887c26630b1aaf5d5083d28d491fe3a1f9ee249f3644ad182ad39650484568f7b2235470e13c916bc29226ed8a6f5efc519c524cab24a8d0b10e755b7
6
+ metadata.gz: f217daf0ae30983a26e0788d0b756af205ea0ab0643fc1f35ce01f56906a3d2577ed6db6256839988e27167384567cff244ec4e7a87187bc1565761b5fe84782
7
+ data.tar.gz: 33b083ff243ece1e473585e3891e1f8a069cc46f50adf5411dc64d2375ff7164381f7b8d59c19dd7287f613c3cecb2a34981355086ca57daf19de53ae78f8ed3
data/README.md CHANGED
@@ -48,9 +48,11 @@
48
48
  - [JSX](#jsx)
49
49
  - [JSON](#json)
50
50
  - [rjs is back!](#rjs-is-back)
51
+ - [Testing your JavaScript](#testing-your-javascript)
51
52
  - [Resolution](#resolution)
52
53
  - [Aliases](#aliases)
53
54
  - [Pre-compilation](#precompilation)
55
+ - [Puma `preload_app!` and Cluster Mode](#puma-preload_app-and-cluster-mode)
54
56
  - [Thanks](#thanks)
55
57
  - [Development](#development)
56
58
 
@@ -663,6 +665,109 @@ console.log(version);
663
665
 
664
666
  Proscenium brings back RJS! Any path ending in .rjs will be served from your Rails app. This allows you to import server rendered javascript.
665
667
 
668
+ ## Testing your JavaScript
669
+
670
+ Your app's JavaScript can be tested with [Bun](https://bun.com), importing exactly what Proscenium
671
+ serves - root-absolute paths, extensionless imports, aliases, `@rubygems/*`, CSS modules, SVG
672
+ components, `proscenium/i18n`, `proscenium.env.*` and `.rjs` - with no dev server running and no
673
+ separate build step.
674
+
675
+ ```bash
676
+ rails generate proscenium:bun
677
+ ```
678
+
679
+ That writes `test/proscenium.preload.js` and adds it to your `bunfig.toml` (merging into an
680
+ existing one rather than replacing it). Then write a test that imports your app code:
681
+
682
+ ```jsx
683
+ // test/js/button.test.jsx
684
+ import { expect, test } from "bun:test";
685
+ import Button from "/app/components/button.jsx";
686
+ import styles from "/app/components/button.module.css";
687
+
688
+ test("the button has its scoped class name", () => {
689
+ expect(styles.button).toEqual(Button.className);
690
+ });
691
+ ```
692
+
693
+ ```bash
694
+ bun test
695
+ ```
696
+
697
+ ### What you test is what you ship
698
+
699
+ Every module you import is fetched through your application's own middleware stack, by a single
700
+ `rails runner` process the preload starts for the run and talks to over a Unix socket. Not rebuilt
701
+ with settings of the test harness's choosing - actually served, the same way a browser request is.
702
+ So your `config.proscenium` settings apply as-is: bundling, minification, code splitting, aliases,
703
+ externals and environment variables are whatever your app is configured to use. `.rjs` files are
704
+ rendered by your own routes.
705
+
706
+ The test file itself is the one exception, because no browser ever requests one: it is built
707
+ directly rather than served, with its output read as a string instead of written, code splitting
708
+ off, `bun:*`/`node:*` treated as external, and its source map inlined. Never minification, which is
709
+ the setting that would actually change what you are testing.
710
+
711
+ This matters more than it sounds. Minification decides the *shape* of a CSS module class name -
712
+ minified you get `button_a1b2c3d4`, unminified `button_a1b2c3d4_app-…` - and your views and your
713
+ stylesheets have to agree on which. So the harness does not get a vote: whatever your app is
714
+ configured to do is what runs. Proscenium's own suite fails if the two ever diverge.
715
+
716
+ Output is minified in production only, so a test failure names a real function on a real line
717
+ rather than a letter at column 80. Note that "production" means a literal `production` Rails
718
+ environment: any name Rails does not recognise - `staging`, `qa`, a per-PR environment - is
719
+ treated as test, and so is served and precompiled unminified.
720
+
721
+ ### Caveats
722
+
723
+ - **`mock.module` does not reach your own modules.** With bundling on - the Rails default - a
724
+ module's dependencies are inlined into it, so there is nothing left for a mock to substitute.
725
+ This catches an existing suite hard. Mock at a boundary the bundle cannot inline: `globalThis`
726
+ (`fetch`, a namespace a script installs), a property the code reads at call time, or a
727
+ test-environment `alias` pointing at a recording stub. Anything Proscenium leaves `external` -
728
+ `.rjs` included, since `*.rjs` is external by default - is resolved by Bun rather than inlined,
729
+ and a mock keyed on the specifier still will not match, because Bun resolves it to the file
730
+ Proscenium materialised. (`mock.module("/lib/api")` also cannot work at all: Bun only passes a
731
+ specifier to a plugin when it contains a `.` or a `:`.)
732
+ - **Put this preload last, and externalise the runner's own tooling.** The plugin's load hook has
733
+ no namespace filter, so once registered it claims every uncached JavaScript module - including
734
+ `@testing-library/react` and whatever else your harness needs from node_modules. Registering it
735
+ after those are loaded and cached is half the answer; adding them to `config.proscenium.external`
736
+ in the test environment is the other, or each served test file gets its own second copy.
737
+ - **An import map is invisible to Bun.** If bare specifiers reach the browser through
738
+ `<script type="importmap">`, mirror the same mapping in `compilerOptions.paths` in
739
+ `tsconfig.json`/`jsconfig.json`, which Bun does read. Otherwise Bun resolves them from
740
+ node_modules and you test a different copy than you ship - a CommonJS React, most likely, whose
741
+ named exports it cannot see.
742
+ - **No cache-busting query strings.** `await import("./thing.js?t=" + Date.now())` to force a
743
+ fresh module cannot work under a bundler, which fixes module identity at build time. Give the
744
+ module no state to reset instead, or reset it explicitly.
745
+ - **`sideEffects` in package.json is enforced.** A bare `import "./thing.js"` for its side effect
746
+ is dropped unless that path is listed there, exactly as in a production build.
747
+ - **Code splitting is off, and the harness turns it off for you.** Nothing on the JavaScript side
748
+ can resolve a `../_asset_chunks/<name>-$HASH$.js` specifier, so the daemon disables splitting in
749
+ its own process rather than asking you to disable it for your whole test environment - where it
750
+ would also cost your system tests the chunked output production emits. The cost is that a
751
+ dynamic `import()` runs here against an inlined module; the chunk fetch itself is a system
752
+ test's job.
753
+ - **Import statically.** Bun does not run a plugin's load hook for a dynamic `import()`, so
754
+ `await import("/lib/thing.js")` inside a test will not go through Proscenium.
755
+ - **`.rjs` actions need `skip_forgery_protection`.** Rails refuses a non-XHR GET that returns
756
+ JavaScript, in the browser as much as under test.
757
+ - **No DOM.** A CSS module still exports its class names, but the `<style>` element Proscenium
758
+ would append is skipped. Add [happy-dom](https://github.com/capricorn86/happy-dom) if you need
759
+ one.
760
+ - **`config.proscenium.bundle = false` needs ESM dependencies.** Unbundled, every module is loaded
761
+ on its own, and a JavaScript runtime cannot load a CommonJS package that way - React 18 included.
762
+ The same is true in the browser.
763
+ - **Stack traces name your functions, not your source lines.** Output is only minified in
764
+ production, so a failure points at a real function name and a real line of the built module.
765
+ It is not mapped back to the original file: a source map is embedded in everything the harness
766
+ builds, but Bun does not apply one to a module a plugin loaded (measured on Bun 1.3.13). The map
767
+ costs nothing to carry, so it stays for when Bun does.
768
+
769
+ - **Node, Deno and Vitest are not supported yet.** They can reuse the same daemon; see `TODOS.md`.
770
+
666
771
  ## Resolution
667
772
 
668
773
  Proscenium will serve files ending with any of these extension: `js,mjs,ts,css,jsx,tsx` from the following directories, and their sub-directories of your Rails application's root: `/app`, `/lib`, `/config`, `/node_modules`, `/vendor`.
@@ -713,6 +818,14 @@ Rails.configuration.proscenium.precompile = Set[
713
818
 
714
819
  This will bundle, code split, tree shake, and compile all your JS, TS, JSX, TSX and CSS files and place them in the `public/assets` directory, ready to be served in production.
715
820
 
821
+ ## Puma `preload_app!` and Cluster Mode
822
+
823
+ Proscenium's builder is backed by a Go shared library, and Go's runtime has a known, unfixed limitation ([golang/go#15538](https://github.com/golang/go/issues/15538)): if the Go runtime has already been initialized in a process before that process calls `fork()`, the forked child's Go runtime is left in a broken state (only the forking thread survives `fork()`; the Go scheduler and GC's other threads simply vanish) and any subsequent call into Go code in that child can hang or fail.
824
+
825
+ This matters if you run Puma in cluster mode with `preload_app!` (the app, including gems, is booted once in the master process, then workers are created via `fork()` with no `exec()` afterward). Proscenium itself never triggers this - simply requiring the gem does not initialize the Go runtime, and nothing in Proscenium's own boot sequence calls into it. The Go runtime only initializes lazily, the first time something actually calls a builder method (`build_to_string`, `resolve`, or `compile`).
826
+
827
+ **Do not call any `Proscenium::Builder` method (directly, or indirectly via the resolver/side-loading) from a Rails initializer or any other code that runs during application boot**, if you use `preload_app!` with `workers`. Doing so initializes the Go runtime in the master process before the fork, and every worker will inherit a broken one. There is no fix available from Go's side - this is a fundamental fork() limitation, not a bug Proscenium can work around. Asset builds and resolves triggered by actual HTTP requests (the normal case) are unaffected, since those always happen after the fork, independently in each worker.
828
+
716
829
  ## Thanks
717
830
 
718
831
  HUGE thanks 🙏 go to [Evan Wallace](https://github.com/evanw) and his amazing [esbuild](https://esbuild.github.io/) project. Proscenium would not be possible without it, and it is esbuild that makes this so fast and efficient.
@@ -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();
@@ -8,19 +8,19 @@ module Proscenium
8
8
 
9
9
  class Result < FFI::Struct
10
10
  layout :success, :bool,
11
- :response, :string,
12
- :content_hash, :string
11
+ :response, :pointer,
12
+ :content_hash, :pointer
13
13
  end
14
14
 
15
15
  class ResolveResult < FFI::Struct
16
16
  layout :success, :bool,
17
- :url_path, :string,
18
- :abs_path, :string
17
+ :url_path, :pointer,
18
+ :abs_path, :pointer
19
19
  end
20
20
 
21
21
  class CompileResult < FFI::Struct
22
22
  layout :success, :bool,
23
- :messages, :string
23
+ :messages, :pointer
24
24
  end
25
25
 
26
26
  module Request
@@ -30,21 +30,27 @@ module Proscenium
30
30
 
31
31
  enum :environment, [:development, 1, :test, :production]
32
32
 
33
+ # `blocking: true` releases the GVL for the duration of the call, so a build/resolve
34
+ # doesn't stall unrelated Ruby threads (eg. other requests in a multi-threaded server).
35
+ # Safe to do because the Go side serialises these calls itself with a mutex - see main.go.
36
+
33
37
  attach_function :build_to_string, [
34
38
  :string, # Path or entry point.
35
39
  :pointer # Config as JSON.
36
- ], Result.by_value
40
+ ], Result.by_value, blocking: true
37
41
 
38
42
  attach_function :resolve, [
39
43
  :string, # path or entry point
40
44
  :pointer # Config as JSON.
41
- ], ResolveResult.by_value
45
+ ], ResolveResult.by_value, blocking: true
42
46
 
43
47
  attach_function :compile, [
44
48
  :pointer # Config as JSON.
45
- ], CompileResult.by_value
49
+ ], CompileResult.by_value, blocking: true
50
+
51
+ attach_function :reset_config, [], :void, blocking: true
46
52
 
47
- attach_function :reset_config, [], :void
53
+ attach_function :free_cstr, [:pointer], :void
48
54
  end
49
55
 
50
56
  class BuildError < Error
@@ -73,16 +79,16 @@ module Proscenium
73
79
  end
74
80
  end
75
81
 
76
- def self.build_to_string(path, root: nil)
77
- new(root:).build_to_string(path)
82
+ def self.build_to_string(path, root: nil, **overrides)
83
+ new(root:, **overrides).build_to_string(path)
78
84
  end
79
85
 
80
- def self.resolve(path, root: nil)
81
- new(root:).resolve(path)
86
+ def self.resolve(path, root: nil, **overrides)
87
+ new(root:, **overrides).resolve(path)
82
88
  end
83
89
 
84
- def self.compile(root: nil)
85
- new(root:).compile
90
+ def self.compile(root: nil, **overrides)
91
+ new(root:, **overrides).compile
86
92
  end
87
93
 
88
94
  # Intended for tests only.
@@ -90,8 +96,13 @@ module Proscenium
90
96
  Request.reset_config
91
97
  end
92
98
 
93
- def initialize(root: nil)
94
- @request_config = FFI::MemoryPointer.from_string({
99
+ # `overrides` are merged over the config derived from `Proscenium.config`, and are passed
100
+ # straight through to the Go side. Intended for callers that are not serving a browser request
101
+ # and so want different build settings - `Bundle: false`, `Write: false`, `CodeSplitting: false`
102
+ # - than the app's own configuration. Keys must match `types.ConfigT`; Go silently ignores
103
+ # any it does not know.
104
+ def initialize(root: nil, **overrides)
105
+ config_hash = {
95
106
  RootPath: (root || Rails.root).to_s,
96
107
  OutputDir: "public#{Proscenium.config.output_dir}",
97
108
  GemPath: gem_root,
@@ -104,12 +115,41 @@ module Proscenium
104
115
  External: Proscenium.config.external,
105
116
  Precompile: Proscenium.config.precompile,
106
117
  Debug: Proscenium.config.debug
107
- }.to_json)
118
+ }.merge(overrides)
119
+
120
+ @request_config = self.class.request_config_pointer(config_hash)
121
+ end
122
+
123
+ class << self
124
+ # Building the config JSON and copying it into an FFI::MemoryPointer is the only real cost
125
+ # in instantiating a Builder (everything else is memoized attribute reads). Since the
126
+ # config is identical across the vast majority of calls (same root, same Rails env, same
127
+ # Proscenium.config), skip re-serializing and re-allocating it when nothing has changed.
128
+ #
129
+ # Callers keep their own reference to the returned pointer, so a later call replacing the
130
+ # memo does not invalidate a pointer already in use.
131
+ #
132
+ # The hash and its pointer are stored as one frozen pair in one ivar, and assigned once. Two
133
+ # ivars would need a lock: a reader could see a hash already updated while its pointer still
134
+ # pointed at the previous config, and get a build made with someone else's settings. One
135
+ # assignment cannot be observed half-done, so a reader sees either the old pair or the new
136
+ # one - never a mix - and there is no window for a test to have to reproduce.
137
+ def request_config_pointer(config_hash)
138
+ memo = @config_memo
139
+ return memo[1] if memo && memo[0] == config_hash
140
+
141
+ pointer = FFI::MemoryPointer.from_string(config_hash.to_json)
142
+ @config_memo = [config_hash, pointer].freeze
143
+
144
+ pointer
145
+ end
108
146
  end
109
147
 
110
148
  def build_to_string(path)
111
149
  ActiveSupport::Notifications.instrument('build.proscenium', identifier: path) do
112
- result = Request.build_to_string(path, @request_config)
150
+ raw = Request.build_to_string(path, @request_config)
151
+ result = { success: raw[:success], response: read_and_free(raw[:response]),
152
+ content_hash: read_and_free(raw[:content_hash]) }
113
153
 
114
154
  raise BuildError.new(path, result[:response]) unless result[:success]
115
155
 
@@ -119,21 +159,35 @@ module Proscenium
119
159
 
120
160
  def resolve(path)
121
161
  ActiveSupport::Notifications.instrument('resolve.proscenium', identifier: path) do
122
- result = Request.resolve(path, @request_config)
162
+ raw = Request.resolve(path, @request_config)
163
+ success = raw[:success]
164
+ url_path = read_and_free(raw[:url_path])
165
+ abs_path = read_and_free(raw[:abs_path])
123
166
 
124
- raise ResolveError.new(path, result[:url_path]) unless result[:success]
167
+ raise ResolveError.new(path, url_path) unless success
125
168
 
126
- [result[:url_path], result[:abs_path]]
169
+ [url_path, abs_path]
127
170
  end
128
171
  end
129
172
 
130
173
  def compile
131
- result = Request.compile(@request_config)
132
- result[:success]
174
+ raw = Request.compile(@request_config)
175
+ read_and_free(raw[:messages])
176
+ raw[:success]
133
177
  end
134
178
 
135
179
  private
136
180
 
181
+ # The Go side allocates each of these strings with C.CString, which the Go runtime cannot
182
+ # see or collect - it must be freed from this side once we're done reading it.
183
+ def read_and_free(ptr)
184
+ return nil if ptr.null?
185
+
186
+ ptr.read_string
187
+ ensure
188
+ Request.free_cstr(ptr)
189
+ end
190
+
137
191
  # Build the ENV variables as determined by `Proscenium.config.env_vars` and
138
192
  # `Proscenium::DEFAULT_ENV_VARS` to pass to esbuild.
139
193
  def env_vars
Binary file
@@ -23,6 +23,8 @@ extern const char *_GoStringPtr(_GoString_ s);
23
23
 
24
24
  #line 3 "main.go"
25
25
 
26
+ #include <stdlib.h>
27
+
26
28
  struct Result {
27
29
  int success;
28
30
  char* response;
@@ -101,6 +103,7 @@ extern "C" {
101
103
  #endif
102
104
 
103
105
  extern void reset_config(void);
106
+ extern void free_cstr(char* ptr);
104
107
  extern struct Result build_to_string(char* filePath, char* configJson);
105
108
  extern struct ResolveResult resolve(char* filePath, char* configJson);
106
109
  extern struct CompileResult compile(char* configJson);
@@ -53,7 +53,9 @@ module Proscenium
53
53
  end
54
54
 
55
55
  transformed_path = ''
56
- if Proscenium.config.debug || Rails.env.development?
56
+ # Mirrors ConfigT#ShouldMinify - the suffix exists whenever identifiers are not
57
+ # minified, and a class name the stylesheet does not define is worse than a long one.
58
+ if Proscenium.config.debug || !Rails.env.production?
57
59
  rel_path = Pathname.new(abs_path).relative_path_from(Rails.root).sub_ext('')
58
60
  transformed_path = "_#{rel_path.to_s.gsub(%r{[@/.+]}, '-')}"
59
61
  end