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,237 @@
1
+ // Starts a Proscenium daemon and registers the Bun plugin against it.
2
+ //
3
+ // The app-side preload is deliberately thin - it locates the gem and calls `register()` - so the
4
+ // protocol, the spawn, and the failure handling all live here and travel with the gem rather than
5
+ // being copy-pasted into every app and drifting.
6
+
7
+ import { mkdtempSync, rmSync } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ const DEFAULT_TIMEOUT_MS = 30_000;
11
+
12
+ class Daemon {
13
+ #socket;
14
+ #pending = new Map();
15
+ #buffer = "";
16
+ #nextId = 1;
17
+
18
+ // Decodes across chunk boundaries. `chunk.toString()` decodes each chunk on its own, so a
19
+ // multi-byte character split by the socket becomes U+FFFD on both sides - a module whose bytes
20
+ // silently differ from the ones Rails served, which is the one thing this harness exists to
21
+ // rule out. Any non-ASCII in app source (an emoji, CJK text, a smart quote esbuild kept) hits
22
+ // it once a reply outgrows one chunk.
23
+ #decoder = new TextDecoder();
24
+
25
+ constructor(socket) {
26
+ this.#socket = socket;
27
+ }
28
+
29
+ static async connect(socketPath) {
30
+ let daemon;
31
+ const socket = await Bun.connect({
32
+ unix: socketPath,
33
+ socket: {
34
+ data: (_sock, chunk) => daemon.#onData(chunk),
35
+ close: () => daemon.#rejectAll(new Error("proscenium daemon closed the connection")),
36
+ error: (_sock, error) => daemon.#rejectAll(error),
37
+ },
38
+ });
39
+ daemon = new Daemon(socket);
40
+ return daemon;
41
+ }
42
+
43
+ send(op, args = {}) {
44
+ return new Promise((resolve, reject) => {
45
+ const id = this.#nextId++;
46
+ this.#pending.set(id, { resolve, reject });
47
+ this.#socket.write(`${JSON.stringify({ id, op, ...args })}\n`);
48
+ });
49
+ }
50
+
51
+ close() {
52
+ this.#socket.end();
53
+ }
54
+
55
+ #onData(chunk) {
56
+ this.#buffer += this.#decoder.decode(chunk, { stream: true });
57
+
58
+ let newline;
59
+ while ((newline = this.#buffer.indexOf("\n")) !== -1) {
60
+ const line = this.#buffer.slice(0, newline);
61
+ this.#buffer = this.#buffer.slice(newline + 1);
62
+ if (!line.trim()) continue;
63
+
64
+ let reply;
65
+ try {
66
+ reply = JSON.parse(line);
67
+ } catch (error) {
68
+ this.#rejectAll(error);
69
+ return;
70
+ }
71
+
72
+ const waiting = this.#pending.get(reply.id);
73
+ if (!waiting) continue;
74
+ this.#pending.delete(reply.id);
75
+
76
+ if (reply.ok) waiting.resolve(reply);
77
+ else waiting.reject(new Error(reply.error ?? "proscenium daemon returned an error"));
78
+ }
79
+ }
80
+
81
+ #rejectAll(error) {
82
+ for (const { reject } of this.#pending.values()) reject(error);
83
+ this.#pending.clear();
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Spawn the daemon and wait for its socket to accept a connection.
89
+ *
90
+ * The socket path is chosen here rather than announced by the child, so nothing is ever read from
91
+ * a pipe. That is not tidiness: under `bun test`, once happy-dom's GlobalRegistrator has run and a
92
+ * DOM-touching package (`@testing-library/react`, say) has been imported, every subsequent
93
+ * `Bun.spawn`/`Bun.spawnSync` with `stdout: "pipe"` yields a zero-length buffer - exit status 0,
94
+ * empty stderr, no bytes. Unix sockets are unaffected, and so is a file-backed stdout. A pipe-based
95
+ * announcement is therefore unusable from the one position in the preload order this plugin can be
96
+ * registered in, which is last, after the app's test tooling is loaded and cached.
97
+ *
98
+ * Rails also fails to boot for ordinary reasons - a pending migration, a database that is not
99
+ * running, a bad initializer - so polling is raced against the child exiting and against a
100
+ * timeout. That turns "bun test hangs with no output", the worst failure shape there is, into a
101
+ * named error with Rails' own stderr already on the terminal.
102
+ */
103
+ async function spawnDaemon({ env, timeoutMs }) {
104
+ // Not os.tmpdir(): a unix socket path is capped at ~104 bytes, and a sandboxed or CI TMPDIR can
105
+ // spend most of that on its own. `/tmp/proscenium-XXXXXX/d.sock` spends 29 of them.
106
+ //
107
+ // The directory is created exclusively and 0700, and the socket lives inside it, because the
108
+ // name alone is no protection: the full path is passed as `rails runner` argv below, so `ps`
109
+ // shows it to every user on the machine, and the client connects to whatever answers - for the
110
+ // seconds Rails takes to boot, with no way to tell its own child from a squatter. Whoever binds
111
+ // first answers every `build`, and a `build` reply is JavaScript this process executes. Same
112
+ // reasoning, and the same fix, as the gem-dir file in the preload template.
113
+ const socketDir = mkdtempSync(join("/tmp", "proscenium-"));
114
+ // Must match Server::SOCKET_NAME. The daemon is handed the directory, not the path.
115
+ const socketPath = join(socketDir, "d.sock");
116
+
117
+ const command = [
118
+ "bundle",
119
+ "exec",
120
+ "rails",
121
+ "runner",
122
+ "-e",
123
+ env,
124
+ 'require "proscenium/runtime/server"; ' +
125
+ `Proscenium::Runtime::Server.start(socket_dir: ${JSON.stringify(socketDir)}, ` +
126
+ `parent_pid: ${process.pid})`,
127
+ ];
128
+
129
+ // No pipes at all, in either direction. The daemon's default parent-liveness watch is EOF on its
130
+ // own stdin, which a pipe from here would provide - but in the poisoned state described above
131
+ // that pipe arrives already closed, so the daemon would shut down the moment it finished
132
+ // booting. `parent_pid` above replaces it with a poll of this process.
133
+ const proc = Bun.spawn(command, { stdin: "ignore", stdout: "inherit", stderr: "inherit" });
134
+
135
+ // Connected with the real client rather than probed and thrown away: `Daemon` wires its
136
+ // handlers up at connect time, and a bare `Bun.connect` with no handlers is not a connection
137
+ // this can hand on.
138
+ //
139
+ // `settled` is what stops the loop. Losing the race below does not cancel a promise, so
140
+ // without it this kept retrying every 50ms for the life of the process after `register()` had
141
+ // already rejected - and a caller that handles the error stays alive, so the loop does too.
142
+ let settled = false;
143
+ const listening = (async () => {
144
+ while (!settled) {
145
+ try {
146
+ return await Daemon.connect(socketPath);
147
+ } catch {
148
+ // Not up yet. The daemon boots Rails first, so the first few attempts always miss.
149
+ await Bun.sleep(50);
150
+ }
151
+ }
152
+ throw new Error("proscenium daemon startup was abandoned");
153
+ })();
154
+
155
+ const exited = proc.exited.then((code) => {
156
+ throw new Error(
157
+ `the proscenium daemon exited with status ${code} before its socket accepted a connection.\n` +
158
+ `Command: ${command.join(" ")}\n` +
159
+ "Its output is above - a Rails boot failure is the usual cause.",
160
+ );
161
+ });
162
+
163
+ let timer;
164
+ const timedOut = new Promise((_resolve, reject) => {
165
+ timer = setTimeout(
166
+ () =>
167
+ reject(
168
+ new Error(
169
+ `the proscenium daemon did not accept a connection within ${timeoutMs}ms.\n` +
170
+ `Command: ${command.join(" ")}`,
171
+ ),
172
+ ),
173
+ timeoutMs,
174
+ );
175
+ });
176
+
177
+ try {
178
+ const client = await Promise.race([listening, exited, timedOut]);
179
+ return { proc, client, socketDir };
180
+ } catch (error) {
181
+ // The timeout path in particular leaves a booted Rails process behind: it never connected,
182
+ // so nothing else is going to close it, and its own parent-pid watch only fires once THIS
183
+ // process exits - which may be a whole test run later.
184
+ proc.kill();
185
+ rmSync(socketDir, { recursive: true, force: true });
186
+ throw error;
187
+ } finally {
188
+ settled = true;
189
+ clearTimeout(timer);
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Start a daemon, register the plugin, and return both so a caller can shut down explicitly.
195
+ *
196
+ * @param {object} [options]
197
+ * @param {string} [options.env] Rails environment for the daemon. Defaults to RAILS_ENV or "test".
198
+ * @param {number} [options.timeoutMs] how long to wait for the daemon to come up.
199
+ * @param {boolean} [options.sourcemaps] inline source maps into built modules. On by default, and
200
+ * free unless the app is unbundled - see the plugin's own note.
201
+ */
202
+ export async function register(options = {}) {
203
+ const env = options.env ?? Bun.env.RAILS_ENV ?? "test";
204
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
205
+ const { proc, client, socketDir } = await spawnDaemon({ env, timeoutMs });
206
+ const { config } = await client.send("handshake");
207
+
208
+ // Imported by its path relative to this file, not by the `pluginPath` the handshake reports.
209
+ // The daemon is whatever answered on the socket, so taking a code path from it would mean
210
+ // importing and running something a peer chose - and this file already knows where its own
211
+ // sibling lives. `pluginPath` stays in the handshake for a client that is not shipped with the
212
+ // gem, which has no such sibling to import.
213
+ const { default: prosceniumPlugin } = await import(new URL("./bun.js", import.meta.url).href);
214
+ Bun.plugin(prosceniumPlugin({ client, config, sourcemaps: options.sourcemaps }));
215
+
216
+ // The daemon polls this process' pid (see `parent_pid` above), so it exits when this process
217
+ // does even if the exit is not a clean one. This is belt and braces for the clean case.
218
+ //
219
+ // The daemon is told to remove the socket directory too (`socket_dir:` above), because this
220
+ // handler is not reliable: measured on Bun 1.3.13, three runs left three empty directories
221
+ // behind even with the `rmSync` below. The daemon's own parent-pid watch exits cleanly and runs
222
+ // its `ensure`, so that is the path that actually cleans up. This stays as the fast case.
223
+ const stop = () => {
224
+ try {
225
+ client.close();
226
+ } catch {
227
+ // Already gone.
228
+ }
229
+ proc.kill();
230
+ rmSync(socketDir, { recursive: true, force: true });
231
+ };
232
+ process.on("exit", stop);
233
+
234
+ return { client, config, proc, stop };
235
+ }
236
+
237
+ export default register;
@@ -0,0 +1,143 @@
1
+ // Bun plugin that hands every module in the graph to Proscenium.
2
+ //
3
+ // Bun asks for /lib/foo.js
4
+ // │
5
+ // ├─ onResolve /^\// ──▶ the real file on disk (synchronous, from a lookup)
6
+ // │
7
+ // └─ onLoad *.js|jsx|ts|tsx ──▶ daemon "build" ──▶ the module exactly as Rails serves
8
+ // it. Any import it still contains is
9
+ // /…-prefixed and extension-bearing
10
+ // ──▶ back to onResolve
11
+ //
12
+ // Nothing here decides how a module is built. The daemon fetches it through the app's own
13
+ // middleware, so bundling, minification and externals are whatever the app configures - which is
14
+ // the whole point: a passing test means the browser gets the same thing. Code splitting is the
15
+ // one setting the daemon overrides, because nothing on this side can resolve a chunk path; see
16
+ // `Server#start`.
17
+ //
18
+ // Four Bun behaviours shape this, all measured rather than assumed:
19
+ //
20
+ // 1. Plugin hooks only see a specifier containing a "." or a ":". Every extensionless form a
21
+ // developer writes is therefore invisible here - which is fine, because Proscenium resolves
22
+ // those before Bun is involved, and everything it emits carries an extension.
23
+ // 2. `onResolve` cannot await a promise ("onResolve() doesn't support pending promises yet"),
24
+ // while `onLoad` can. Since a module's imports are only resolved after its contents load,
25
+ // `build` returns those imports already resolved and `onResolve` is a synchronous lookup.
26
+ // 3. Exactly ONE `onLoad` may be registered. Register a second and both stop behaving, whether
27
+ // the two live in one plugin or in two. So this dispatches by extension inside one hook.
28
+ // 4. A namespaced module can only be imported dynamically - a static `import` of one fails with
29
+ // "Cannot find module 'ns:/path'". App code uses static imports, so nothing here is virtual:
30
+ // every resolved path is a real file, which is also why the daemon materialises `.rjs`.
31
+
32
+ import { realpathSync } from "node:fs";
33
+
34
+ const LOADABLE = /\.(jsx?|tsx?|mjs|cjs|css)$/;
35
+ // Tolerates a trailing newline: JS `$` does not match before one without the `m` flag, and an
36
+ // inlined map arrives with one where the appended external comment does not.
37
+ const SOURCEMAP_COMMENT = /\n\/\/# sourceMappingURL=[^\n]*\n?$/;
38
+ const INLINE_SOURCEMAP = /sourceMappingURL=data:/;
39
+
40
+ /**
41
+ * @param {object} options
42
+ * @param {{send: (op: string, args?: object) => Promise<object>}} options.client daemon connection
43
+ * @param {object} [options.config] the daemon's handshake payload
44
+ * @param {boolean} [options.sourcemaps] inline source maps so stack traces point at real source.
45
+ * On by default - served output is minified, so a trace without one is unreadable. Free for a
46
+ * module the daemon builds, which embeds the map in the same build. Only an unbundled app pays
47
+ * anything: its modules are served, so each map is a second build (see TODOS.md).
48
+ * @returns {import("bun").BunPlugin}
49
+ */
50
+ export default function prosceniumPlugin({ client, config, sourcemaps = true }) {
51
+ // Written by onLoad, read synchronously by onResolve. Keyed by url path, and again by the
52
+ // absolute path it maps to, so onLoad can recover the url path Bun was originally asked for. A
53
+ // specifier resolves to the same place for the life of a run, so nothing is ever invalidated.
54
+ const byUrlPath = new Map();
55
+ const byAbsPath = new Map();
56
+
57
+ function remember(urlPath, hit) {
58
+ byUrlPath.set(urlPath, hit);
59
+ byAbsPath.set(hit.absPath, hit);
60
+
61
+ // pnpm links packages into node_modules, and Bun hands onLoad the real path behind the link
62
+ // while Proscenium reports the linked one. Keying both makes the lookup independent of which
63
+ // form arrives.
64
+ try {
65
+ byAbsPath.set(realpathSync(hit.absPath), hit);
66
+ } catch {
67
+ // Nothing on disk under that path - a materialised or generated module. The direct key is
68
+ // the only one that matters.
69
+ }
70
+ }
71
+
72
+ async function urlPathFor(absPath) {
73
+ const hit = byAbsPath.get(absPath);
74
+ if (hit) return hit.urlPath;
75
+
76
+ const fresh = await client.send("resolve", { path: absPath });
77
+ remember(fresh.urlPath, fresh);
78
+ return fresh.urlPath;
79
+ }
80
+
81
+ async function build(urlPath) {
82
+ const { code, imports } = await client.send("build", {
83
+ path: urlPath,
84
+ sourcemap: sourcemaps,
85
+ });
86
+
87
+ // Every import this module makes, resolved ahead of the resolve hook that cannot await.
88
+ for (const [urlPath, hit] of Object.entries(imports ?? {})) remember(urlPath, hit);
89
+
90
+ // A module the daemon *served* - what Rails hands a browser - still carries an external
91
+ // `//# sourceMappingURL=` line pointing at a file Bun will never fetch, so it goes either
92
+ // way. Only the entry point is built here, and the daemon has already honoured the flag for
93
+ // that one.
94
+ if (!sourcemaps) return code.replace(SOURCEMAP_COMMENT, "");
95
+
96
+ // Already inlined by the build, which is one build rather than two. Only a served module
97
+ // arrives with a map still to fetch, and fetching it costs a second build of that module.
98
+ if (INLINE_SOURCEMAP.test(code)) return code;
99
+
100
+ try {
101
+ const { code: map } = await client.send("build", { path: `${urlPath}.map` });
102
+ const encoded = Buffer.from(map, "utf8").toString("base64");
103
+ return code.replace(
104
+ SOURCEMAP_COMMENT,
105
+ `\n//# sourceMappingURL=data:application/json;base64,${encoded}`,
106
+ );
107
+ } catch {
108
+ // A missing or unbuildable map is not worth failing a test run over.
109
+ return code.replace(SOURCEMAP_COMMENT, "");
110
+ }
111
+ }
112
+
113
+ return {
114
+ name: "proscenium",
115
+ config,
116
+
117
+ setup(build_) {
118
+ // Only ever sees paths Proscenium emitted: root-absolute, extension-bearing url paths, each
119
+ // already resolved by the build of the module that imports it.
120
+ build_.onResolve({ filter: /^\// }, (args) => {
121
+ const hit = byUrlPath.get(args.path);
122
+
123
+ // Not from a Proscenium build - a genuine filesystem import, or the entry test file. Let
124
+ // Bun resolve it and report its own error rather than masking it.
125
+ if (!hit) return undefined;
126
+
127
+ return { path: hit.absPath };
128
+ });
129
+
130
+ build_.onLoad({ filter: LOADABLE }, async (args) => {
131
+ // Importing a plain stylesheet from JS yields an empty object, which is exactly what a
132
+ // bundled build produces for it (`var css_import_default = {}`) - the stylesheet itself
133
+ // becomes a separate CSS output. A CSS *module* never reaches this branch: Proscenium
134
+ // inlines it into the importing JS, class-name Proxy and all.
135
+ if (args.path.endsWith(".css")) {
136
+ return { contents: "export default {};", loader: "js" };
137
+ }
138
+
139
+ return { contents: await build(await urlPathFor(args.path)), loader: "js" };
140
+ });
141
+ },
142
+ };
143
+ }