ruact 0.0.16 → 0.0.17

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -1
  3. data/lib/ruact/client_manifest.rb +11 -2
  4. data/lib/ruact/controller.rb +3 -1
  5. data/lib/ruact/doctor.rb +64 -4
  6. data/lib/ruact/flight/renderer.rb +23 -9
  7. data/lib/ruact/flight/request.rb +31 -2
  8. data/lib/ruact/flight/row_emitter.rb +9 -0
  9. data/lib/ruact/flight/serializer.rb +22 -7
  10. data/lib/ruact/render_pipeline.rb +10 -3
  11. data/lib/ruact/testing/component_query.rb +11 -4
  12. data/lib/ruact/testing/flight_wire_parser.rb +2 -2
  13. data/lib/ruact/version.rb +1 -1
  14. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +44 -2
  15. data/vendor/javascript/vite-plugin-ruact/flight-conformance.test.mjs +286 -0
  16. data/vendor/javascript/vite-plugin-ruact/index.js +89 -14
  17. data/vendor/javascript/vite-plugin-ruact/package-lock.json +804 -3
  18. data/vendor/javascript/vite-plugin-ruact/package.json +5 -1
  19. data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +53 -0
  20. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +46 -17
  21. data/vendor/javascript/vite-plugin-ruact/runtime/flight-modules.js +40 -0
  22. data/vendor/javascript/vite-plugin-ruact/runtime/flight-webpack-globals.js +15 -0
  23. data/vendor/javascript/vite-plugin-ruact/runtime/root-boundary.js +41 -0
  24. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +65 -83
  25. data/vendor/javascript/vite-plugin-ruact/runtime/suspense-boundary.js +83 -0
  26. data/vendor/javascript/vite-plugin-ruact/runtime/transport.js +41 -0
  27. data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/LICENSE +21 -0
  28. data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/VERSION.json +5 -0
  29. data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/client.browser.development.js +5473 -0
  30. data/vendor/javascript/vite-plugin-ruact/runtime/vendor/react-server-dom-webpack/client.browser.production.js +2126 -0
  31. data/vendor/javascript/vite-plugin-ruact/scripts/vendor-flight-client.mjs +124 -0
  32. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +14 -0
  33. metadata +13 -4
  34. data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +0 -499
  35. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +0 -436
@@ -0,0 +1,286 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // Story 18-1 — React's Flight client (the copy vendored in the gem) reads what
4
+ // the Ruby side writes. Every payload here is the serializer's own output,
5
+ // written and guarded against drift by spec/ruact/flight/*_fixtures_spec.rb.
6
+ //
7
+ // The suite runs once per client build: FLIGHT_CLIENT_MODE=development (the
8
+ // default) and =production (`npm test` runs both). CI also runs it against the
9
+ // lowest React the gem supports.
10
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { act, createElement as h, Suspense } from "react";
14
+ import { createRoot } from "react-dom/client";
15
+ import { createFromReadableStream } from "virtual:ruact/flight-client";
16
+ import { setModuleRegistry } from "./runtime/flight-modules.js";
17
+ import { setBoundaryErrorHandler } from "./runtime/suspense-boundary.js";
18
+ import { RootBoundary } from "./runtime/root-boundary.js";
19
+ import { labelledStream } from "./runtime/transport.js";
20
+
21
+ const MODE = process.env.FLIGHT_CLIENT_MODE || "development";
22
+ const FIXTURES = path.join(import.meta.dirname, "../../../spec/fixtures/flight");
23
+ const read = (name) => new Uint8Array(fs.readFileSync(path.join(FIXTURES, name)));
24
+ const readJSON = (name) => JSON.parse(fs.readFileSync(path.join(FIXTURES, name), "utf8"));
25
+
26
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
27
+
28
+ function Bad() {
29
+ throw new Error("boom");
30
+ }
31
+
32
+ function LikeButton({ likes, tags }) {
33
+ return h("button", null, `${likes}:${Array.isArray(tags) ? `[${tags.join(",")}]` : String(tags)}`);
34
+ }
35
+ setModuleRegistry({ "/LikeButton.jsx": { LikeButton }, "/Bad.jsx": { Bad } });
36
+
37
+ function streamOf(chunks) {
38
+ return new ReadableStream({
39
+ start(controller) {
40
+ for (const chunk of chunks) controller.enqueue(chunk);
41
+ controller.close();
42
+ },
43
+ });
44
+ }
45
+
46
+ // As the runtime reads a response: through ./runtime/transport.js. React's
47
+ // client returns a thenable, not a Promise; wrap it so `rejects` really checks.
48
+ const decode = (chunks) => Promise.resolve(createFromReadableStream(labelledStream(streamOf(chunks))));
49
+
50
+ async function mount(value) {
51
+ const container = document.createElement("div");
52
+ const root = createRoot(container);
53
+ await act(async () => {
54
+ root.render(h(Suspense, { fallback: "…" }, value));
55
+ });
56
+ return { container, root };
57
+ }
58
+
59
+ let consoleError;
60
+ beforeEach(() => {
61
+ consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
62
+ });
63
+ afterEach(() => {
64
+ consoleError.mockRestore();
65
+ setBoundaryErrorHandler(null);
66
+ });
67
+
68
+ describe(`React's Flight client (${MODE}) reads a page the Ruby side rendered`, () => {
69
+ // The development wire carries the slots React's development client reads.
70
+ const fixture = MODE === "development" ? "conformance_tree_dev.txt" : "conformance_tree.txt";
71
+ const html =
72
+ "<main><h1>Posts</h1>" +
73
+ "<article><button>1:[only]</button></article>" +
74
+ "<article><button>2:[only]</button></article>" +
75
+ "<article><button>3:[only]</button></article>" +
76
+ "<p>late</p><p>after</p></main>";
77
+
78
+ // FIRST render in this file on purpose: React reports a missing key once per
79
+ // location, so a later test would see nothing even on the wrong wire.
80
+ it("renders host elements, an ERB loop of client components and a Suspense child — and logs nothing", async () => {
81
+ const { container, root } = await mount(decode([read(fixture)]));
82
+ expect(container.innerHTML).toBe(html);
83
+ // The development build reports a missing key after the commit, on a later
84
+ // task; wait for it before concluding there is none.
85
+ await new Promise((resolve) => setTimeout(resolve, 50));
86
+ expect(consoleError).not.toHaveBeenCalled();
87
+ act(() => root.unmount());
88
+ });
89
+
90
+ it("keeps a one-element array prop a one-element array (Story 17.0a)", async () => {
91
+ const tree = await decode([read(fixture)]);
92
+ const article = tree.props.children[1];
93
+ expect(article.props.children.props.tags).toEqual(["only"]);
94
+ });
95
+
96
+ it("does not depend on where the network splits the bytes", async () => {
97
+ const bytes = read(fixture);
98
+ for (let cut = 1; cut < bytes.length; cut += 37) {
99
+ const { container, root } = await mount(decode([bytes.subarray(0, cut), bytes.subarray(cut)]));
100
+ expect(container.innerHTML).toBe(html);
101
+ act(() => root.unmount());
102
+ }
103
+ });
104
+ });
105
+
106
+ describe(`text rows (${MODE}) — Story 17-0d`, () => {
107
+ const expected = readJSON("text_framing_expected.json");
108
+
109
+ it("decodes every value the serializer framed, byte for byte", async () => {
110
+ expect(await decode([read("text_framing.txt")])).toEqual(expected);
111
+ });
112
+
113
+ it("decodes the same split at every byte, including inside lengths and code points", async () => {
114
+ const bytes = read("text_framing.txt");
115
+ for (let cut = 1; cut < bytes.length; cut += 1) {
116
+ expect(await decode([bytes.subarray(0, cut), bytes.subarray(cut)])).toEqual(expected);
117
+ }
118
+ });
119
+
120
+ it("decodes one byte at a time", async () => {
121
+ const bytes = read("text_framing.txt");
122
+ expect(await decode([...bytes].map((b) => Uint8Array.of(b)))).toEqual(expected);
123
+ });
124
+
125
+ it("puts long text inside Suspense content where the deferred row points", async () => {
126
+ const { container, root } = await mount(decode([read("text_framing_suspense.txt")]));
127
+ expect(container.querySelector("p").textContent).toBe(expected.multibyte);
128
+ act(() => root.unmount());
129
+ });
130
+
131
+ it("rejects a stream cut inside a text row instead of rendering part of it", async () => {
132
+ const bytes = read("text_framing.txt");
133
+ const cut = Buffer.from(bytes).indexOf(":T") + 10;
134
+ await expect(decode([bytes.subarray(0, cut)])).rejects.toThrow(/the response ended before the whole page arrived/);
135
+ });
136
+ });
137
+
138
+ describe(`dates and big integers (${MODE}) — Story 17-0c`, () => {
139
+ // Expected JSON tags what JSON cannot hold: { date: ISO } and { bigint: "…" }.
140
+ const revive = (value) => {
141
+ if (Array.isArray(value)) return value.map(revive);
142
+ if (value && typeof value === "object") {
143
+ if ("date" in value) return new Date(value.date);
144
+ if ("bigint" in value) return BigInt(value.bigint);
145
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, revive(v)]));
146
+ }
147
+ return value;
148
+ };
149
+
150
+ it("rebuilds every value with its JS type, nested ones included", async () => {
151
+ expect(await decode([read("scalar_round_trip.txt")])).toEqual(revive(readJSON("scalar_round_trip_expected.json")));
152
+ });
153
+
154
+ it("keeps a literal string that starts with $D a string", async () => {
155
+ expect((await decode([read("scalar_round_trip.txt")])).literal).toBe("$D2026-01-01 is a string");
156
+ });
157
+
158
+ it("reads NaN, ±Infinity, undefined and an escaped $", async () => {
159
+ const value = await decode([new TextEncoder().encode(
160
+ '0:{"nan":"$NaN","inf":"$Infinity","ninf":"$-Infinity","negz":"$-0","und":"$undefined","cash":"$$5"}\n',
161
+ )]);
162
+ expect(Number.isNaN(value.nan)).toBe(true);
163
+ expect(value.inf).toBe(Infinity);
164
+ expect(value.ninf).toBe(-Infinity);
165
+ expect(Object.is(value.negz, -0)).toBe(true);
166
+ expect(value.und).toBeUndefined();
167
+ expect(value.cash).toBe("$5");
168
+ });
169
+ });
170
+
171
+ describe(`a Suspense child whose row is an error (${MODE})`, () => {
172
+ const timeout = () => read("conformance_suspense_timeout.txt");
173
+ // The same page, its Suspense row now content: the timeout fixture with the
174
+ // error row replaced.
175
+ const recovered = () => new TextEncoder().encode(
176
+ new TextDecoder().decode(timeout()).replace(/^1:E.*$/m, '1:["$","p",null,{"children":"late"}]'),
177
+ );
178
+
179
+ it("keeps the fallback on screen and reports the server's message, in production too", async () => {
180
+ const reported = [];
181
+ setBoundaryErrorHandler((error) => reported.push(error));
182
+ const { container, root } = await mount(decode([timeout()]));
183
+
184
+ expect(container.innerHTML).toBe("<span>loading</span>");
185
+ expect(reported.map((e) => e.message)).toEqual(["[ruact] Server error: Suspense timeout exceeded"]);
186
+ act(() => root.unmount());
187
+ });
188
+
189
+ it("keeps the fallback when the response ends before the Suspense row, in production too", async () => {
190
+ const reported = [];
191
+ setBoundaryErrorHandler((error) => reported.push(error));
192
+ const cut = new TextDecoder().decode(timeout()).replace(/^1:E.*\n?/m, "");
193
+ const tree = await decode([new TextEncoder().encode(cut)]);
194
+ const container = document.createElement("div");
195
+ const root = createRoot(container);
196
+ await act(async () => {
197
+ root.render(h(RootBoundary, { tree }, h(Suspense, { fallback: "…" }, tree)));
198
+ });
199
+ expect(container.innerHTML).toBe("<span>loading</span>");
200
+ expect(reported.map((e) => e.message)).toEqual([
201
+ "[ruact] Server error: the response ended before the whole page arrived",
202
+ ]);
203
+ act(() => root.unmount());
204
+ });
205
+
206
+ it("keeps the fallback when the network fails mid-response", async () => {
207
+ const reported = [];
208
+ setBoundaryErrorHandler((error) => reported.push(error));
209
+ const head = new TextDecoder().decode(timeout()).replace(/^1:E.*\n?/m, "");
210
+ const failing = new ReadableStream({
211
+ start(controller) {
212
+ controller.enqueue(new TextEncoder().encode(head));
213
+ setTimeout(() => controller.error(new TypeError("network error")), 0);
214
+ },
215
+ });
216
+ const tree = await createFromReadableStream(labelledStream(failing));
217
+ const container = document.createElement("div");
218
+ const root = createRoot(container);
219
+ await act(async () => {
220
+ root.render(h(RootBoundary, { tree }, h(Suspense, { fallback: "…" }, tree)));
221
+ await new Promise((resolve) => setTimeout(resolve, 10));
222
+ });
223
+ expect(container.innerHTML).toBe("<span>loading</span>");
224
+ expect(reported).toHaveLength(1);
225
+ act(() => root.unmount());
226
+ });
227
+
228
+ it("does not stay failed: the next response for the same page renders", async () => {
229
+ setBoundaryErrorHandler(() => {});
230
+ const { container, root } = await mount(decode([timeout()]));
231
+ expect(container.innerHTML).toBe("<span>loading</span>");
232
+
233
+ // What revalidate() or a navigation to a page of the same shape does.
234
+ const next = decode([recovered()]);
235
+ await act(async () => {
236
+ root.render(h(Suspense, { fallback: "…" }, next));
237
+ });
238
+ expect(container.innerHTML).toBe("<p>late</p>");
239
+ act(() => root.unmount());
240
+ });
241
+
242
+ it("lets a crash in the app's own component through, to the root's boundary", async () => {
243
+ const reported = [];
244
+ setBoundaryErrorHandler((error) => reported.push(error));
245
+ const wire = new TextDecoder().decode(timeout())
246
+ .replace(/^1:E.*$/m, '1:["$","$L5",null,{}]')
247
+ .replace(/^/, '5:I["/Bad.jsx",[],"Bad"]\n');
248
+ const tree = await decode([new TextEncoder().encode(wire)]);
249
+ const container = document.createElement("div");
250
+ const root = createRoot(container);
251
+ await act(async () => {
252
+ root.render(h(RootBoundary, { tree }, tree));
253
+ });
254
+ expect(container.textContent).toBe("[ruact] Error: boom");
255
+ expect(reported).toEqual([]);
256
+ act(() => root.unmount());
257
+ });
258
+ });
259
+
260
+ describe(`a component missing from the registry (${MODE})`, () => {
261
+ it("shows an error in place of the page instead of removing it", async () => {
262
+ const tree = await decode([new TextEncoder().encode('1:I["/Gone.jsx",[],"Gone"]\n0:["$","$L1",null,{}]\n')]);
263
+ const container = document.createElement("div");
264
+ const root = createRoot(container);
265
+ await act(async () => {
266
+ root.render(h(RootBoundary, { tree }, tree));
267
+ });
268
+ expect(container.textContent).toBe(
269
+ '[ruact] Error: client component not registered: /Gone.jsx — is it under app/javascript/components/ with "use client" at the top?',
270
+ );
271
+ act(() => root.unmount());
272
+ });
273
+ });
274
+
275
+ describe(`responses the router reads (${MODE})`, () => {
276
+ it("decodes a redirect body to the object the router checks", async () => {
277
+ // redirect_row.txt is what Ruact::Controller#redirect_to writes (controller_spec guards it).
278
+ expect(await decode([read("redirect_row.txt")])).toEqual({ redirectUrl: "/posts/1", redirectType: "push" });
279
+ });
280
+
281
+ it("rejects a stream that ends before its root row", async () => {
282
+ await expect(decode([new TextEncoder().encode('1:["$","p",null,{}]\n')])).rejects.toThrow(
283
+ /the response ended before the whole page arrived/,
284
+ );
285
+ });
286
+ });
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { createRequire } from "node:module";
4
5
  import { installServerFunctionsHooks } from "./server-functions-codegen.mjs";
5
6
 
6
7
  /**
@@ -56,6 +57,11 @@ const RESOLVED_REGISTRY_ID = "\0" + REGISTRY_VIRTUAL_ID;
56
57
  // `/@id/__x00__virtual:ruact/bootstrap`; prod Vite-manifest key
57
58
  // `virtual:ruact/bootstrap`). Mirrors REGISTRY_VIRTUAL_ID exactly.
58
59
  export const BOOTSTRAP_VIRTUAL_ID = "virtual:ruact/bootstrap";
60
+
61
+ // Story 18-1 — React's Flight client, as the runtime imports it. The plugin
62
+ // decides which copy answers (see `flightClientSource`).
63
+ export const FLIGHT_CLIENT_VIRTUAL_ID = "virtual:ruact/flight-client";
64
+ const RESOLVED_FLIGHT_CLIENT_ID = "\0" + FLIGHT_CLIENT_VIRTUAL_ID;
59
65
  const RESOLVED_BOOTSTRAP_ID = "\0" + BOOTSTRAP_VIRTUAL_ID;
60
66
 
61
67
  // The gem-shipped runtime sources the virtual bootstrap pulls in. Resolved
@@ -71,18 +77,30 @@ export default function ruact(options = {}) {
71
77
 
72
78
  let root;
73
79
  let manifest = {};
80
+ let flightClient; // { source: "app" | "vendored", version, mode }
74
81
 
75
82
  return installServerFunctionsHooks({
76
83
  name: "vite-plugin-ruact",
77
84
 
85
+ // Story 18-1 — the runtime (and React's Flight client vendored with it) is
86
+ // served from the gem's directory, not the app's. Its `react` and
87
+ // `react-dom` imports must still be the app's: one React instance.
88
+ config() {
89
+ return { resolve: { dedupe: ["react", "react-dom"] } };
90
+ },
91
+
78
92
  configResolved(config) {
79
93
  root = config.root;
94
+ flightClient = resolveFlightClient(root, config.isProduction ? "production" : "development");
95
+ // Dev server and build alike, so a build log shows it too.
96
+ config.logger?.info?.(`[ruact] Flight client: ${describeFlightClient(flightClient)}`);
80
97
  },
81
98
 
82
99
  // Story 10.1b / 14.2 — resolve ruact's virtual module ids.
83
100
  resolveId(id) {
84
101
  if (id === REGISTRY_VIRTUAL_ID) return RESOLVED_REGISTRY_ID;
85
102
  if (id === BOOTSTRAP_VIRTUAL_ID) return RESOLVED_BOOTSTRAP_ID;
103
+ if (id === FLIGHT_CLIENT_VIRTUAL_ID) return RESOLVED_FLIGHT_CLIENT_ID;
86
104
  return null;
87
105
  },
88
106
 
@@ -96,6 +114,7 @@ export default function ruact(options = {}) {
96
114
  // Story 14.2 — serve the gem-shipped bootstrap source, with its relative
97
115
  // runtime imports rewritten to absolute fs specifiers (see below).
98
116
  if (id === RESOLVED_BOOTSTRAP_ID) return generateBootstrapSource();
117
+ if (id === RESOLVED_FLIGHT_CLIENT_ID) return flightClientSource(flightClient);
99
118
  return null;
100
119
  },
101
120
 
@@ -303,28 +322,84 @@ export function generateRegistrySource(manifest) {
303
322
  // Story 14.2 (FR104) — render the virtual bootstrap source from the gem-shipped
304
323
  // `runtime/bootstrap.jsx`. A `load` hook returns module TEXT whose relative
305
324
  // imports resolve against the resolved id (`\0virtual:ruact/bootstrap`), which
306
- // is NOT a filesystem path — so `./flight-client.js` / `./ruact-router.js`
307
- // would fail. We rewrite those two specifiers to ABSOLUTE fs specifiers into the
325
+ // is NOT a filesystem path — so `./flight-modules.js` / `./ruact-router.js`
326
+ // would fail. We rewrite those specifiers to ABSOLUTE fs specifiers into the
308
327
  // gem `runtime/` dir (the same technique `generateRegistrySource` uses via
309
328
  // `toImportSpecifier`). The bare `react` / `react-dom/client` specifiers and the
310
329
  // `virtual:ruact/registry` id are left untouched — Vite resolves them from the
311
330
  // app root (so React comes from the USER's node_modules: one React instance).
312
331
  export function generateBootstrapSource(runtimeDir = RUNTIME_DIR) {
313
332
  const src = fs.readFileSync(path.join(runtimeDir, "bootstrap.jsx"), "utf8");
314
- const abs = (name) => toImportSpecifier(path.join(runtimeDir, name));
315
- // Rewrite EVERY `from './<runtime>.js'` import specifier to its absolute fs
316
- // path. Matching the quoted `from '...'` form (not the bare filename) avoids
317
- // hitting prose mentions of the file in comments, and the global regex
318
- // tolerates either quote style. The replacement is supplied as a function so a
319
- // `$` in the absolute path is never treated as a replacement pattern.
320
- const rewrite = (code, name) => {
321
- const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
322
- return code.replace(
323
- new RegExp(`from\\s+(['"])\\./${escaped}\\1`, "g"),
324
- () => `from '${abs(name)}'`,
333
+ // Rewrite EVERY relative `from './<runtime>.js'` and side-effect
334
+ // `import './<runtime>.js'` specifier to its absolute fs path. Matching the
335
+ // quoted specifier after `from`/`import` (not the bare filename) leaves prose
336
+ // mentions of a file in comments alone, and either quote style is accepted.
337
+ // The replacement is a function so a `$` in the absolute path is never read
338
+ // as a replacement pattern.
339
+ return src.replace(
340
+ /\b(from|import)(\s+)(['"])\.\/([\w.-]+\.js)\3/g,
341
+ (_match, keyword, space, _quote, name) =>
342
+ `${keyword}${space}'${toImportSpecifier(path.join(runtimeDir, name))}'`,
343
+ );
344
+ }
345
+
346
+ // Story 18-1 (packaging option C, docs/internal/decisions/flight-client-compatibility.md)
347
+ // — which copy of React's Flight client the runtime uses. The app's own
348
+ // `react-server-dom-webpack` when the app declares it in its package.json (an
349
+ // app that needs a different React version installs it); otherwise the copy
350
+ // vendored in the gem, which brings no `webpack` into the app's install. A copy
351
+ // that only resolves — another package's dependency, hoisted — is not chosen:
352
+ // it was not picked for this app's React.
353
+ export function resolveFlightClient(appRoot, mode, runtimeDir = RUNTIME_DIR) {
354
+ const vendored = () => {
355
+ const { version } = JSON.parse(
356
+ fs.readFileSync(path.join(runtimeDir, "vendor", "react-server-dom-webpack", "VERSION.json"), "utf8"),
325
357
  );
358
+ return { source: "vendored", version, mode };
326
359
  };
327
- return rewrite(rewrite(src, "flight-client.js"), "ruact-router.js");
360
+ let declared = false;
361
+ try {
362
+ const app = JSON.parse(fs.readFileSync(path.join(appRoot, "package.json"), "utf8"));
363
+ declared = Boolean(app.dependencies?.["react-server-dom-webpack"] ?? app.devDependencies?.["react-server-dom-webpack"]);
364
+ } catch {
365
+ return vendored();
366
+ }
367
+ if (!declared) return vendored();
368
+ try {
369
+ const require = createRequire(path.join(appRoot, "package.json"));
370
+ const pkg = require("react-server-dom-webpack/package.json");
371
+ return { source: "app", version: pkg.version, mode };
372
+ } catch {
373
+ // Declared but not installed: npm install has not run. The vendored copy
374
+ // keeps the page working meanwhile.
375
+ return vendored();
376
+ }
377
+ }
378
+
379
+ export function describeFlightClient({ source, version, mode }) {
380
+ return source === "app"
381
+ ? `react-server-dom-webpack ${version} from the app (${mode})`
382
+ : `react-server-dom-webpack ${version} vendored in ruact (${mode})`;
383
+ }
384
+
385
+ // The `virtual:ruact/flight-client` module. The vendored copy is an ES module
386
+ // whose webpack hooks are module-local. The app's copy is CommonJS reading
387
+ // webpack's globals, which must exist before it evaluates — hence the
388
+ // side-effect import first.
389
+ export function flightClientSource({ source, mode }, runtimeDir = RUNTIME_DIR) {
390
+ const names = "createFromReadableStream, createFromFetch";
391
+ if (source === "app") {
392
+ const globals = toImportSpecifier(path.join(runtimeDir, "flight-webpack-globals.js"));
393
+ return [
394
+ `import '${globals}';`,
395
+ `export { ${names} } from 'react-server-dom-webpack/client.browser';`,
396
+ "",
397
+ ].join("\n");
398
+ }
399
+ const vendored = toImportSpecifier(
400
+ path.join(runtimeDir, "vendor", "react-server-dom-webpack", `client.browser.${mode}.js`),
401
+ );
402
+ return `export { ${names} } from '${vendored}';\n`;
328
403
  }
329
404
 
330
405
  // A bundler import specifier for an absolute fs path. Vite/Rollup resolve