proscenium 0.24.2 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +113 -0
- data/lib/generators/proscenium/bun/bun_generator.rb +150 -0
- data/lib/generators/proscenium/bun/templates/proscenium.preload.js +61 -0
- data/lib/proscenium/builder.rb +78 -24
- data/lib/proscenium/ext/proscenium +0 -0
- data/lib/proscenium/ext/proscenium.h +3 -0
- data/lib/proscenium/importer.rb +3 -1
- data/lib/proscenium/runtime/bootstrap.js +237 -0
- data/lib/proscenium/runtime/bun.js +143 -0
- data/lib/proscenium/runtime/server.rb +681 -0
- data/lib/proscenium/version.rb +1 -1
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5678d705a5367a5c7e3e66e6bcf1fe9099b59df1e9b7c10d15005ee4b02059bd
|
|
4
|
+
data.tar.gz: e75ff41a520488a59f9676d004411b0253ef2041f72c25ca56601858ef9642c8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6a073addba16a7adb71ddf5e64afcd4486cd4b5f228271c48ed507ce46d2f1c7bf736e1f358982b57f15bf186b9fd4409f2d3aea81318f8209cd81837148b534
|
|
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();
|
data/lib/proscenium/builder.rb
CHANGED
|
@@ -8,19 +8,19 @@ module Proscenium
|
|
|
8
8
|
|
|
9
9
|
class Result < FFI::Struct
|
|
10
10
|
layout :success, :bool,
|
|
11
|
-
:response, :
|
|
12
|
-
:content_hash, :
|
|
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, :
|
|
18
|
-
:abs_path, :
|
|
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, :
|
|
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 :
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
}.
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
167
|
+
raise ResolveError.new(path, url_path) unless success
|
|
125
168
|
|
|
126
|
-
[
|
|
169
|
+
[url_path, abs_path]
|
|
127
170
|
end
|
|
128
171
|
end
|
|
129
172
|
|
|
130
173
|
def compile
|
|
131
|
-
|
|
132
|
-
|
|
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);
|
data/lib/proscenium/importer.rb
CHANGED
|
@@ -53,7 +53,9 @@ module Proscenium
|
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
transformed_path = ''
|
|
56
|
-
|
|
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
|