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
|
@@ -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
|