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,702 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'securerandom'
5
+ require 'fileutils'
6
+ require 'json'
7
+ require 'socket'
8
+ require 'tmpdir'
9
+
10
+ module Proscenium
11
+ module Runtime
12
+ # A long-lived Rails process that answers build and resolve requests from a JavaScript test
13
+ # runner. Launched by the runner's preload script, one per test run.
14
+ #
15
+ # preload.js Server (this class)
16
+ # │ │
17
+ # ├── spawn ─────────────────────────▶ boots Rails, then listens. Prints the socket
18
+ # │ │ path to stdout only when it chose the path
19
+ # │ │ itself; a caller that supplies one is told
20
+ # │ │ nothing, because it already knows.
21
+ # ├── connect(socket) ───────────────▶ UNIXServer
22
+ # │ │
23
+ # ├── {"id":1,"op":"handshake"} ─────▶ pool ──▶ config + pluginPath
24
+ # ├── {"id":2,"op":"resolve",...} ───▶ pool ──▶ Resolver.resolve
25
+ # ├── {"id":3,"op":"build",...} ─────▶ pool ──▶ Rails.application.call, or the
26
+ # │ │ builder for the entry point
27
+ # └── {"id":4,"op":"shutdown"} ──────▶ pool ──▶ closes the listener
28
+ #
29
+ # There is no `rjs` op: a `.rjs` path is fetched by `build` like anything else, and happens to
30
+ # be answered by the app's own route.
31
+ #
32
+ # Why a socket rather than stdio: `rails runner CODE` boots the whole application before
33
+ # evaluating CODE, so anything an initializer or a gem prints to stdout is already on the wire
34
+ # before this class runs. A socket cannot be written to by accident.
35
+ class Server
36
+ # Modules the runtime provides itself, added to `External` for the entry-point build only.
37
+ # See `build_entry` for why.
38
+ RUNTIME_MODULES = ['bun:*', 'node:*'].freeze
39
+
40
+ # What a module may come back as. A source map is JSON; everything else a JavaScript runtime
41
+ # can import is JavaScript. Anything else - an HTML error page, most usefully - is refused
42
+ # rather than handed to the runtime to choke on.
43
+ CONTENT_TYPES = {
44
+ '.map' => ['application/json'],
45
+ '.css' => ['text/css'],
46
+ :default => ['application/javascript', 'text/javascript']
47
+ }.freeze
48
+
49
+ # `import "/x.js"`, `import y from "/x.js"`, `export * from "/x.js"`, `import("/x.js")` and
50
+ # `require("/x.js")`, with or without the whitespace a minifier removes.
51
+ #
52
+ # The lookbehind is what keeps `Array.from("/x.json")` and `Buffer.from("/x.json")` out. A
53
+ # word boundary is not enough: `.` is a non-word character, so `\bfrom` matches the `from` in
54
+ # any `<expr>.from(` call - and every match costs a full `Rails.application.call`, which is
55
+ # exactly the spurious dispatch this regex exists to prevent.
56
+ IMPORT_SPECIFIER = %r{(?<![.$\w])(?:from|import|require)\s*\(?\s*["'](/[^"'\s]+\.\w+)["']}
57
+
58
+ # The socket's name inside its directory. Fixed, so a client that made the directory can name
59
+ # the path without being told it.
60
+ SOCKET_NAME = 'd.sock'
61
+
62
+ # Where a module with no file of its own - `.rjs`, rendered by a route - is written, so the
63
+ # test runner has a real path to import it from. Relative to Rails.root.
64
+ MATERIALISED_DIR = 'tmp/proscenium/served'
65
+
66
+ class ProtocolError < StandardError; end
67
+
68
+ def self.start(**)
69
+ new(**).start
70
+ end
71
+
72
+ # Removes every materialised module. Exposed because `handle` can be driven without ever
73
+ # calling `start` - a test, or another runtime's adapter.
74
+ def clear_materialised
75
+ FileUtils.rm_rf(Rails.root.join(MATERIALISED_DIR))
76
+ end
77
+
78
+ # @param socket_dir [String] a private directory to put the socket in. Supplying one also
79
+ # suppresses the announcement, since the caller can name `SOCKET_NAME` inside it itself.
80
+ # Defaults to one this class creates. Either way it is removed on shutdown.
81
+ # @param threads [Integer] size of the worker pool.
82
+ # @param stdout [IO] where the socket path is announced.
83
+ # @param watch [IO, nil] closing this stream shuts the server down. Defaults to $stdin, which
84
+ # the parent process holds open, so the server cannot outlive the test run. Ignored when
85
+ # parent_pid is given.
86
+ # @param parent_pid [Integer, nil] poll this process instead of watching a stream, and shut
87
+ # down once it is gone. For a parent that cannot hold a pipe open - see `watch_parent`.
88
+ def initialize(socket_dir: nil, threads: 4, stdout: $stdout, watch: $stdin, parent_pid: nil)
89
+ # Nothing to announce when the caller supplied the directory - it can name the socket
90
+ # itself, and this process' stdout is the terminal in that case.
91
+ @announce = socket_dir.nil?
92
+ @socket_dir = socket_dir
93
+ @threads = threads
94
+ @stdout = stdout
95
+ @watch = parent_pid ? nil : watch
96
+ @parent_pid = parent_pid
97
+ @requests = Queue.new
98
+ @cache = {}
99
+ @cache_mutex = Mutex.new
100
+ @resolve_mutex = Mutex.new
101
+ @socket_mutex = Mutex.new
102
+ @shutdown = false
103
+ end
104
+
105
+ # A socket inside a private directory rather than a predictable name in a shared one. The
106
+ # old default was `proscenium-<pid>.sock` in Dir.tmpdir: a name anyone on the machine can
107
+ # guess from `ps`, in a directory anyone can write to, and this daemon answers `build` with
108
+ # code the client executes. `mktmpdir` is 0700 and its create is exclusive, so there is
109
+ # nobody else in the directory to race. `/tmp` explicitly, not Dir.tmpdir, because a unix
110
+ # socket path is capped near 104 bytes and a CI or sandboxed TMPDIR can spend most of it.
111
+ #
112
+ # Except on Windows, which has no `/tmp`. Dir.tmpdir there is normally the per-user
113
+ # `AppData\Local\Temp`, which is short enough and private to the user by its default ACL -
114
+ # which matters, because the 0700 mktmpdir asks for means nothing on Windows. That is a
115
+ # default, not a guarantee: TMP or TEMP can point somewhere shared. Only a caller that
116
+ # supplies no socket_dir lands here; the bun test harness always supplies one, under the
117
+ # app's own tmp/.
118
+ #
119
+ # Resolved on first use rather than in the constructor, because `handle` can be driven
120
+ # without ever binding anything - most of this class' own tests do - and a constructor that
121
+ # creates a directory would leave one behind for every such instance.
122
+ #
123
+ # Guarded, because `start` reads it on the server thread while a caller may read it on
124
+ # another: an unguarded `||=` let both see nil and make a directory each, and the caller then
125
+ # waited for a socket the server had not bound.
126
+ def socket_path
127
+ @socket_mutex.synchronize do
128
+ @socket_dir ||= Dir.mktmpdir('proscenium-', Gem.win_platform? ? Dir.tmpdir : '/tmp')
129
+ @socket_path ||= File.join(@socket_dir, SOCKET_NAME)
130
+ end
131
+ end
132
+
133
+ def start
134
+ # Captured first and put back in the ensure below, because the write further down is to
135
+ # process-wide config. A real daemon is spawned per test run and exits, so restoring it
136
+ # there changes nothing - but `start` is also called in-process by this class' own tests,
137
+ # and a write left standing would decide the build settings of every test that happened
138
+ # to run after them, under whatever order minitest picked.
139
+ code_splitting_was = Proscenium.config.code_splitting
140
+
141
+ # A precompiled manifest would make `Resolver.resolve` hand back digest URLs from
142
+ # public/assets instead of resolving the source. Nothing reloads it after this.
143
+ Proscenium::Manifest.reset!
144
+
145
+ # Code splitting off for everything this daemon hands back, not just the entry point.
146
+ # `build_entry` already passes `CodeSplitting: false` for the one module it builds
147
+ # directly, but a module that comes from `serve` is built by the app's own middleware
148
+ # under the app's own config - so an app whose test files sit under a path Proscenium
149
+ # serves has its entry point built with splitting on, and a dynamic `import()` in it comes
150
+ # back as `../_asset_chunks/<name>-$HASH$.js`. A browser resolves that specifier against
151
+ # the request URL and Proscenium serves it; a client of this daemon resolves it against
152
+ # the importing file's path on disk, where nothing exists. The app's config is right for
153
+ # the app and wrong here.
154
+ #
155
+ # Set here rather than asked of each app, because it is a property of this transport: no
156
+ # client of this daemon can resolve a chunk path, and an app that turned splitting off to
157
+ # satisfy `bun test` would be turning it off for its system tests too, which drive a real
158
+ # browser and should keep the chunked output production emits.
159
+ #
160
+ # The cost, named rather than called parity: a module containing a dynamic `import()` is
161
+ # compiled differently here, not merely divided up differently. With splitting on the
162
+ # `import()` stays a fetch of a chunk; with it off esbuild inlines the imported module
163
+ # into the bundle and rewrites the call to `Promise.resolve().then(...)`. Every other
164
+ # module is byte-identical, so the one thing `bun test` cannot cover is the fetch itself -
165
+ # which is a system test's job anyway.
166
+ Proscenium.config.code_splitting = false
167
+
168
+ # One daemon per test run, so this clears materialised modules between runs - including
169
+ # after a run that died without shutting down. Cleared at boot rather than at exit so the
170
+ # files are still there to look at when a run fails.
171
+ clear_materialised
172
+
173
+ FileUtils.rm_f(socket_path)
174
+ @server = UNIXServer.new(socket_path)
175
+
176
+ workers = Array.new(@threads) { worker }
177
+ watch_parent
178
+
179
+ announce
180
+ accept_loop
181
+
182
+ # Tell the workers to stop BEFORE waiting for them. `shutdown!` pushes the sentinels, and
183
+ # it only runs in the ensure below - so joining first meant every join timed out and a
184
+ # shutdown took a second per worker longer than it needed to.
185
+ stop_workers
186
+ workers.each { |t| t.join(1) }
187
+ ensure
188
+ # `shutdown!` first: it is the cleanup that leaves something behind on disk if it is
189
+ # skipped, and it swallows its own errors, so the restore below always runs.
190
+ shutdown!
191
+ Proscenium.config.code_splitting = code_splitting_was
192
+ end
193
+
194
+ # Answer one request. Public so it can be driven directly from a test without a socket.
195
+ #
196
+ # Never raises. The rescue reports on the `id` captured before the work started, rather than
197
+ # reading it out of the request again: `42` and `null` are both valid JSON lines, so a
198
+ # request is not necessarily a Hash, and re-reading `request['id']` in the rescue reproduced
199
+ # the very error it was handling - which escaped, killed the worker thread, and then killed
200
+ # the whole daemon when `Thread#join` re-raised it into `start`.
201
+ #
202
+ # @param request [Hash] with String keys. Anything else is answered, not raised on.
203
+ # @return [Hash] the reply, always carrying `id` and `ok`.
204
+ def handle(request)
205
+ id = request.is_a?(Hash) ? request['id'] : nil
206
+
207
+ unless request.is_a?(Hash)
208
+ return { id: id, ok: false,
209
+ error: "ProtocolError: expected a JSON object, got #{request.class}" }
210
+ end
211
+
212
+ op = request['op']
213
+
214
+ reply = case op
215
+ when 'handshake' then op_handshake
216
+ when 'resolve' then op_resolve(request)
217
+ when 'build' then op_build(request)
218
+ when 'shutdown' then op_shutdown
219
+ else raise ProtocolError, "unknown op #{op.inspect}"
220
+ end
221
+
222
+ { id: id, ok: true }.merge(reply)
223
+ rescue StandardError => e
224
+ { id: id, ok: false, error: "#{e.class}: #{e.message}" }
225
+ end
226
+
227
+ private
228
+
229
+ def op_handshake
230
+ {
231
+ config: {
232
+ root: Rails.root.to_s,
233
+ gemPath: Proscenium.root.to_s,
234
+ pluginPath: File.expand_path('bun.js', __dir__),
235
+ rubyGems: Proscenium::BundledGems.paths,
236
+ aliases: Proscenium.config.aliases,
237
+ environment: Rails.env.to_s
238
+ }
239
+ }
240
+ end
241
+
242
+ # Resolution is Ruby's job, in both directions. `Resolver.resolve` already knows the corners -
243
+ # which paths belong to a bundled gem, which gem root a virtual `@rubygems/*` path maps to,
244
+ # and that the `proscenium` gem's own root is a subdirectory - and reimplementing that in
245
+ # JavaScript would be a third copy of the same rule.
246
+ #
247
+ # Returns everything the JS side needs from one round trip:
248
+ # urlPath - the browser-facing path, which is also the module's identity and what `build`
249
+ # is given
250
+ # absPath - the real file on disk, for the runtime's own resolver
251
+ def op_resolve(request)
252
+ path = request.fetch('path')
253
+ refuse_while_installing!
254
+
255
+ # `Resolver.resolved` is a plain class-level Hash with no synchronisation, so concurrent
256
+ # resolves would race on it. Resolution is cheap and memoised, so serialising it costs
257
+ # little; the expensive work (build) stays parallel.
258
+ _manifest, url_path, abs_path = @resolve_mutex.synchronize do
259
+ Proscenium::Resolver.resolve(path, as_array: true)
260
+ end
261
+
262
+ { urlPath: url_path, absPath: abs_path }
263
+ end
264
+
265
+ # Returns the module exactly as Rails would serve it, together with every import inside it,
266
+ # already resolved.
267
+ #
268
+ # The module is fetched through the middleware stack rather than rebuilt with settings of
269
+ # this daemon's own choosing. That is what parity means: the same `Proscenium.config`, so the
270
+ # same bundling, minification and externals a browser gets. Code splitting is the single
271
+ # exception - `start` turns it off process-wide, because no client of this daemon can resolve
272
+ # a chunk path; see there for the cost. Any other setting decided here would be a way for a
273
+ # test to pass against something the app does not serve - and minification alone changes CSS
274
+ # module class names, so "close enough" is not.
275
+ #
276
+ # Bun's resolve hook cannot await a promise, so a module's imports are resolved here too, in
277
+ # the same round trip, and the plugin answers from a lookup table.
278
+ def op_build(request)
279
+ path = url_path!(request.fetch('path'))
280
+
281
+ # The client decides whether it wants a map, because it is the one that would throw it
282
+ # away. Without this, `register({ sourcemaps: false })` still paid for a base64 blob
283
+ # bigger than the code itself: generated, JSON-encoded, pushed over the socket, cached,
284
+ # and then stripped by the plugin.
285
+ sourcemap = request.fetch('sourcemap', true) ? true : false
286
+
287
+ refuse_while_installing!
288
+ cached(path, sourcemap) do
289
+ code = serve_or_build(path, sourcemap: sourcemap)
290
+
291
+ # esbuild passes a mis-encoded source's bytes through, and scanning them below would
292
+ # raise a bare "invalid byte sequence in UTF-8" that names no module.
293
+ unless code.valid_encoding?
294
+ raise EncodingError, "#{path} built to output that is not valid UTF-8"
295
+ end
296
+
297
+ # A source map has no imports to resolve, and the client discards the field, so scanning
298
+ # it is work nobody reads.
299
+ path.end_with?('.map') ? { code: code } : { code: code, imports: resolve_imports(code) }
300
+ end
301
+ end
302
+
303
+ # Everything `build` is given has to be a url path - the thing a browser would put in a GET -
304
+ # because two of the places it ends up do not anchor it themselves. `File.join(Rails.root,
305
+ # spec)` keeps a `..` verbatim, and `build_entry` hands the path to esbuild, which resolves
306
+ # it relative to the app root and will happily climb out; the `serve` path is the only one
307
+ # that gets normalised for free, by `Middleware.normalise_path`. A traversal 404s
308
+ # there and then lands in `build_entry`, so without this guard `/lib/../../../secrets.js`
309
+ # reads and returns any file esbuild can load.
310
+ #
311
+ # A scheme is refused for a second reason: `Rack::MockRequest.env_for` accepts a full URL and
312
+ # takes SERVER_NAME from it, so a caller could otherwise choose the host the app sees and
313
+ # walk straight past `config.hosts`.
314
+ #
315
+ # `resolve` deliberately does NOT go through this - it is given absolute filesystem paths on
316
+ # purpose, including ones outside the root, because that is where a gem or a `link:`ed
317
+ # package lives.
318
+ def url_path!(path)
319
+ unless path.start_with?('/') && !path.start_with?('//') && !path.include?('://')
320
+ raise ProtocolError, "#{path.inspect} is not a url path"
321
+ end
322
+
323
+ expanded = File.expand_path(path.delete_prefix('/'), Rails.root)
324
+ unless expanded == Rails.root.to_s || expanded.start_with?("#{Rails.root}/")
325
+ raise ProtocolError, "#{path.inspect} resolves outside the application root"
326
+ end
327
+
328
+ path
329
+ end
330
+
331
+ # Proscenium serves anything under its own path globs. A test file is not one of those - no
332
+ # browser ever asks for it - so it is the one module built directly.
333
+ def serve_or_build(path, sourcemap: true)
334
+ serve(path) || build_entry(path, sourcemap: sourcemap)
335
+ end
336
+
337
+ # The entry point is the one module a browser never requests, so it is built rather than
338
+ # served. Four departures, none of which changes a single byte of app code:
339
+ #
340
+ # Write - its output is read as a string and never served, so writing it is litter.
341
+ # External - `bun:test` and `node:*` come from the runtime. Without this the build fails to
342
+ # resolve them, since bundled mode treats an unresolvable bare import as an
343
+ # error rather than a warning.
344
+ # Splitting- with `Write: false` a shared chunk is never written anywhere, so a test file
345
+ # containing a dynamic `import()` would come back importing
346
+ # `../_asset_chunks/<name>-$HASH$.js` - a path with nothing behind it. `start`
347
+ # turns this off process-wide too; kept here because `handle` can be driven
348
+ # without ever calling `start`.
349
+ # Sourcemap- inlined, because a separate `.map` is a second full build of the same module,
350
+ # and the client only ever turns it into a data URL anyway. A browser wants the
351
+ # separate file it can fetch on demand; nothing here is a browser.
352
+ #
353
+ # Notably NOT minification. Bundling inlines app modules into this build, so unminified here
354
+ # would mean unminified class names for every CSS module the test imports - names the app
355
+ # never emits. Legible failures come from the inlined source map instead.
356
+ def build_entry(path, sourcemap: true)
357
+ external = Proscenium.config.external.to_a + RUNTIME_MODULES
358
+
359
+ Proscenium::Builder.build_to_string(
360
+ path.delete_prefix('/'),
361
+ Write: false, External: external, CodeSplitting: false, SourcemapInline: sourcemap
362
+ )[:response]
363
+ end
364
+
365
+ # A GET through the full middleware stack, as a browser makes. Returns nil when Proscenium
366
+ # does not serve this path, so the caller can fall back to building it.
367
+ #
368
+ # An unservable path does not necessarily 404: with `show_exceptions` off, which is the test
369
+ # default, Rails raises instead. Either way it means "not ours".
370
+ def serve(path)
371
+ status, headers, body = Rails.application.call(rack_env_for(path))
372
+
373
+ content = +''
374
+ body.each { |chunk| content << chunk }
375
+ # A file served as it is, through ActionDispatch::FileHandler, comes in binary chunks.
376
+ # Everything served here is text, which the content type guard below ensures.
377
+ content.force_encoding(Encoding::UTF_8)
378
+
379
+ return nil if status == 404
380
+
381
+ guard_response!(path, status, headers, content)
382
+ rescue ActionController::RoutingError
383
+ nil
384
+ ensure
385
+ body.close if body.respond_to?(:close)
386
+ end
387
+
388
+ # Root-absolute, extension-bearing specifiers are what Proscenium emits, and the only thing
389
+ # the plugin's `^/` filter will be asked about. A specifier that cannot be resolved is
390
+ # skipped rather than failing the build: a genuinely broken import fails more clearly at
391
+ # resolve time.
392
+ #
393
+ # Anchored on import syntax rather than on any quoted path-shaped string. An app constant
394
+ # like "/api/v1/thing.json" is not an import, and treating it as one meant a full
395
+ # `Rails.application.call` per data literal - which for a route with side effects is a
396
+ # request the developer never wrote. Handles minified output too, where the space goes:
397
+ # `from"/x.js"`.
398
+ def resolve_imports(code)
399
+ code.scan(IMPORT_SPECIFIER).flatten.uniq.each_with_object({}) do |spec, acc|
400
+ acc[spec] = resolution_for(spec)
401
+ rescue StandardError
402
+ next
403
+ end
404
+ end
405
+
406
+ # Bun needs a real file for every import: a namespaced virtual module can only be imported
407
+ # dynamically, and app code imports statically. Most specifiers already have one. Those that
408
+ # do not - `.rjs`, rendered by a route - are written under `tmp/` exactly as served, without
409
+ # being rebuilt, because unaltered is what the browser executes.
410
+ def resolution_for(spec)
411
+ # The same guard `op_build` gets, and for the same two reasons - this specifier came out
412
+ # of a regex scan of built output, which includes every string literal in every bundled
413
+ # dependency. `File.join(Rails.root, spec)` keeps a `..` verbatim, and `serve` would take
414
+ # SERVER_NAME from a `//host/x.js` shape and walk past `config.hosts`.
415
+ spec = url_path!(spec)
416
+
417
+ return op_resolve('path' => spec) if File.exist?(File.join(Rails.root, spec))
418
+
419
+ code = serve(spec)
420
+ raise ProtocolError, "#{spec} is not served by this app" if code.nil?
421
+
422
+ relative = File.join(MATERIALISED_DIR, "#{Digest::SHA1.hexdigest(spec)[0, 12]}.js")
423
+ target = Rails.root.join(relative)
424
+ FileUtils.mkdir_p(target.dirname)
425
+ write_atomically(target, code)
426
+
427
+ { urlPath: spec, absPath: target.to_s, materialised: true }
428
+ end
429
+
430
+ # A plain GET, deliberately. Marking it XHR would silence
431
+ # ActionController::InvalidCrossOriginRequest, but it also changes Rails' format negotiation -
432
+ # a route that renders HTML to a browser renders JavaScript to an XHR - so the harness would
433
+ # be testing a different response than production serves.
434
+ #
435
+ # A browser importing `/foo.rjs` from a module also issues a non-XHR GET returning
436
+ # JavaScript, so an app serving `.rjs` already needs `skip_forgery_protection` on that
437
+ # action. Leaving the request faithful means the developer sees that requirement here rather
438
+ # than in production.
439
+ def rack_env_for(path)
440
+ # SERVER_NAME explicitly: `env_for` defaults it to `example.org`, which `config.hosts`
441
+ # permits in test (it is empty there) and refuses in development, where Rails installs an
442
+ # allowlist of `.localhost`/`.test`. Without this, `register({ env: "development" })` -
443
+ # a documented option - 403s on every single module.
444
+ Rack::MockRequest.env_for(path, 'SERVER_NAME' => 'localhost')
445
+ end
446
+
447
+ # Written to a unique path and renamed, because rename is atomic within a filesystem. The
448
+ # check-then-write this replaces could interleave between two workers materialising the same
449
+ # module, and a reader could see a partly written file.
450
+ def write_atomically(target, code)
451
+ return if target.exist? && target.read == code
452
+
453
+ temp = target.sub_ext(".#{SecureRandom.hex(8)}.tmp")
454
+ temp.write(code)
455
+ File.rename(temp, target)
456
+ ensure
457
+ temp&.delete if temp&.exist?
458
+ end
459
+
460
+ def op_shutdown
461
+ @shutdown = true
462
+ @server&.close
463
+ {}
464
+ end
465
+
466
+ # A Rack response is always a response, even when it went wrong. Handing a 404's HTML error
467
+ # page to a JavaScript runtime produces a parse error pointing at line 1 of the developer's
468
+ # own file, which says nothing about the missing route that actually caused it.
469
+ def guard_response!(path, status, headers, content)
470
+ unless (200..299).cover?(status)
471
+ raise ProtocolError, "#{path} returned #{status}\n#{content.truncate(2000)}"
472
+ end
473
+
474
+ expected = CONTENT_TYPES.fetch(File.extname(path), CONTENT_TYPES[:default])
475
+ type = headers.find { |k, _| k.to_s.downcase == 'content-type' }&.last.to_s
476
+
477
+ unless expected.any? { |t| type.start_with?(t) }
478
+ raise ProtocolError,
479
+ "#{path} returned content type #{type.inspect}, expected one of #{expected.inspect}"
480
+ end
481
+
482
+ content
483
+ end
484
+
485
+ # Before the cache, so an earlier result is not handed out while an install changes what it
486
+ # resolved against, or while contexts are stale (C24).
487
+ def refuse_while_installing!
488
+ reason = Proscenium::Builder.refusal
489
+ raise reason if reason
490
+ end
491
+
492
+ # Each module variant - its path and whether it carries a source map - holds the mtime of its
493
+ # source file, so an edit is picked up between runs of a watching runner.
494
+ #
495
+ # ponytail: the mtime covers the keyed file only, and a bundled build inlines its whole
496
+ # graph - which is the default, so in `--watch` editing any module a test imports can serve
497
+ # the previous bundle until the test file itself is touched. Unbundled it is narrower but
498
+ # still real: a CSS module, an SVG and i18n data are inlined either way. `Metafile: true` is
499
+ # already set in internal/builder/build.go, so keying on the newest mtime across the
500
+ # metafile's inputs is the fix when this bites.
501
+ #
502
+ # Held across the build, not just around the hash read, so N concurrent requests for the same
503
+ # module build it once and the rest wait for that result. A single mutex around the whole
504
+ # thing would serialise every build and defeat the worker pool, so the lock is per variant.
505
+ #
506
+ # A path with nothing on disk to key on is not cached at all. `.rjs` is rendered by a route,
507
+ # so its bytes depend on app code that can change; keying it on a nil mtime made the key a
508
+ # constant and pinned the first render for the life of the daemon. That is invisible in a
509
+ # one-shot run and wrong in a watching one - a suite passing against bytes the app no longer
510
+ # produces. A route render is cheap next to a build, so it happens each time instead.
511
+ def cached(path, *extra)
512
+ mtime = mtime_of(path)
513
+ return yield if mtime.nil?
514
+
515
+ # One bucket per module variant. A new mtime replaces the bucket, lock and all, which is
516
+ # what keeps a long `--watch` session at one entry per module - and holds for a build that
517
+ # raised, which leaves its bucket without a value rather than a lock outside the cache.
518
+ key = [path, Proscenium::Builder.generation, *extra]
519
+ bucket = @cache_mutex.synchronize do
520
+ current = @cache[key]
521
+ next current if current && current[:mtime] == mtime
522
+
523
+ @cache[key] = { mtime: mtime, mutex: Mutex.new }
524
+ end
525
+
526
+ bucket[:mutex].synchronize do
527
+ bucket.key?(:value) ? bucket[:value] : (bucket[:value] = yield)
528
+ end
529
+ end
530
+
531
+ # `path` is a url path, so its leading slash has to go before it can be joined - otherwise
532
+ # Pathname#join treats it as absolute and stats the wrong file entirely.
533
+ #
534
+ # A source map is keyed on its source file: nothing is written under the `.map` name, but its
535
+ # contents track the file it describes, which is the thing that changes.
536
+ def mtime_of(path)
537
+ target = path.delete_suffix('.map').delete_prefix('/')
538
+
539
+ File.mtime(Rails.root.join(target)).to_f
540
+ rescue SystemCallError
541
+ nil
542
+ end
543
+
544
+ # A worker must not be able to die. `Thread#join` re-raises a dead thread's exception into
545
+ # whoever joins it, and `start` joins every worker - so one unhandled error in here took the
546
+ # entire daemon down mid-suite, not just the one request.
547
+ def worker
548
+ Thread.new do
549
+ while (job = @requests.pop)
550
+ socket, request, write_mutex = job
551
+ answer(socket, write_mutex, handle(request))
552
+ end
553
+ end
554
+ end
555
+
556
+ # `JSON.generate` raises `JSON::GeneratorError` - a StandardError, not an IOError - when the
557
+ # reply carries bytes that are not valid UTF-8. `op_build` checks its own output first and
558
+ # names the module; this is the backstop for any other reply, reported as a failure rather
559
+ # than taking the daemon with it.
560
+ def answer(socket, write_mutex, reply)
561
+ line = begin
562
+ JSON.generate(reply)
563
+ rescue StandardError => e
564
+ JSON.generate({ id: reply[:id], ok: false,
565
+ error: "#{e.class}: #{e.message} - the built output is not valid UTF-8" })
566
+ end
567
+
568
+ write_mutex.synchronize { socket.puts(line) }
569
+ rescue IOError, Errno::EPIPE
570
+ # The runner went away mid-request. Nothing to report it to.
571
+ nil
572
+ end
573
+
574
+ # The run is over when the parent is gone - cleanly or otherwise - and this process must not
575
+ # survive it.
576
+ #
577
+ # Two ways to notice, because one of them is unavailable to a Bun test runner. The stream
578
+ # watch is the better signal: the parent holds $stdin open for the life of the run, so EOF
579
+ # arrives the moment it exits. But under `bun test`, once happy-dom's GlobalRegistrator has
580
+ # run and a DOM-touching package has been imported, every pipe Bun opens to a child is
581
+ # broken from the start - the child sees immediate EOF on stdin and the parent captures no
582
+ # stdout - so a runner in that position passes `parent_pid` and this polls instead.
583
+ def watch_parent
584
+ return watch_parent_pid if @parent_pid
585
+ return unless @watch
586
+
587
+ Thread.new do
588
+ @watch.read
589
+ rescue IOError
590
+ nil
591
+ ensure
592
+ op_shutdown
593
+ end
594
+ end
595
+
596
+ # `kill(0)` signals nothing; it just asks whether the process is still there. This daemon is
597
+ # a direct child of the process it watches, so when that process exits this one is reparented
598
+ # and the pid stops resolving - which is the signal. A second of latency past the end of a
599
+ # test run costs nothing.
600
+ #
601
+ # ESRCH only, deliberately: EPERM means the process is alive and merely belongs to someone
602
+ # else, which cannot happen for a parent that spawned this one, and treating it as death
603
+ # would shut down a running test suite.
604
+ def watch_parent_pid
605
+ Thread.new do
606
+ loop do
607
+ sleep 1
608
+
609
+ begin
610
+ Process.kill(0, @parent_pid)
611
+ rescue Errno::ESRCH
612
+ break
613
+ end
614
+ end
615
+
616
+ op_shutdown
617
+ end
618
+ end
619
+
620
+ def announce
621
+ return unless @announce
622
+
623
+ @stdout.puts(socket_path)
624
+ @stdout.flush
625
+ end
626
+
627
+ def accept_loop
628
+ until @shutdown
629
+ begin
630
+ socket = @server.accept
631
+ rescue IOError, Errno::EBADF
632
+ break
633
+ end
634
+
635
+ Thread.new { read_requests(socket) }
636
+ end
637
+ end
638
+
639
+ # Reads newline-framed JSON. A line can arrive split across reads, so partial input is held
640
+ # in a buffer until its terminator shows up.
641
+ def read_requests(socket)
642
+ write_mutex = Mutex.new
643
+ buffer = +''
644
+
645
+ while (chunk = socket.readpartial(16_384))
646
+ buffer << chunk
647
+
648
+ while (newline = buffer.index("\n"))
649
+ line = buffer.slice!(0..newline).chomp
650
+ next if line.empty?
651
+
652
+ begin
653
+ @requests << [socket, JSON.parse(line), write_mutex]
654
+ rescue JSON::ParserError => e
655
+ # The stream is desynchronised, and a reply cannot help: every reply is matched to a
656
+ # request by `id`, and a line that would not parse has none to echo. A client that
657
+ # correlates on id drops an id-less reply and waits for a reply that never comes. So
658
+ # close the connection instead - the client's close handler fails every request in
659
+ # flight with a named error, which is a diagnosable end rather than a hang.
660
+ report_unparseable(socket, write_mutex, e)
661
+
662
+ return
663
+ end
664
+ end
665
+ end
666
+ rescue IOError, Errno::ECONNRESET
667
+ nil
668
+ ensure
669
+ socket.close unless socket.closed?
670
+ end
671
+
672
+ # Best effort: the client is told why the connection is about to close, but it may already be
673
+ # gone, and there is nowhere to report that.
674
+ def report_unparseable(socket, write_mutex, error)
675
+ write_mutex.synchronize do
676
+ socket.puts(JSON.generate({ ok: false, error: "invalid JSON: #{error.message}" }))
677
+ end
678
+ rescue IOError, Errno::EPIPE
679
+ nil
680
+ end
681
+
682
+ # A nil per worker: `Queue#pop` blocks, so a worker only notices a shutdown by being handed
683
+ # one. Idempotent, because `shutdown!` runs on every exit path as well.
684
+ def stop_workers
685
+ @threads.times { @requests << nil }
686
+ end
687
+
688
+ def shutdown!
689
+ @shutdown = true
690
+ stop_workers
691
+ @server&.close unless @server&.closed?
692
+ # nil when `socket_path` raised before it could memoise - `mktmpdir` on a /tmp this
693
+ # process cannot write to, most usefully. `rm_f(nil)` raises TypeError, which the rescue
694
+ # below does not catch, so the useful error was replaced by a confusing one.
695
+ FileUtils.rm_f(@socket_path) if @socket_path
696
+ FileUtils.remove_entry(@socket_dir) if @socket_dir && Dir.exist?(@socket_dir)
697
+ rescue SystemCallError, IOError
698
+ nil
699
+ end
700
+ end
701
+ end
702
+ end