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