@nifrajs/web-react 0.1.0-alpha.1 → 0.1.0-alpha.3

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.
package/README.md CHANGED
@@ -16,3 +16,11 @@ bun add @nifrajs/web-react
16
16
  - AI-readable: <https://nifra.dev/llms.txt>
17
17
 
18
18
  MIT
19
+
20
+ ## For AI agents
21
+
22
+ Start with [`LLM.md`](./LLM.md) — this package's contract card (the exports you call + its footguns),
23
+ one cheap read instead of the whole corpus. For the wider framework: the repo's
24
+ [`AGENTS.md`](../../AGENTS.md) is the copy-paste quick reference, and
25
+ [`llms-full.txt`](../../llms-full.txt) is the full machine-readable corpus. Run `nifra check` as the
26
+ done-gate, or `nifra mcp` to give the agent live project tools.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AASjD,6EAA6E;AAC7E,eAAO,MAAM,YAAY,EAAE,aAmB1B,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AASjD,6EAA6E;AAC7E,eAAO,MAAM,YAAY,EAAE,aAyB1B,CAAA"}
package/dist/index.js CHANGED
@@ -3,21 +3,27 @@
3
3
  * `renderToReadableStream`; the layout-chain fold is in `./compose`. Client hydration lives in
4
4
  * `@nifrajs/web-react/client`. React's JSX is Bun-native — no build plugin needed.
5
5
  */
6
- import { renderToString as reactRenderToString, renderToReadableStream } from "react-dom/server";
7
6
  import { compose } from "./compose.js";
7
+ import { reactDomServer } from "./react-dom-server.js";
8
8
  /** The React server render adapter — pass to @nifrajs/web's `renderPage`. */
9
9
  export const reactAdapter = {
10
10
  // Synchronous one-pass render for non-deferred pages (renderPage's buffered fast path). React's
11
11
  // `renderToString` emits the same hydration-compatible markup as the Fizz stream for non-Suspense
12
12
  // content and is markedly cheaper than `renderToReadableStream` — most visibly on Bun, where Fizz's
13
13
  // streaming machinery is the heaviest. A page that defer()s/Suspends takes `renderToStream` below.
14
- renderToString(chain, props) {
15
- return reactRenderToString(compose(chain, props));
14
+ //
15
+ // Async because the `react-dom/server` module is resolved lazily (from the consumer app's node_modules
16
+ // under Bun runtime SSR — see ./react-dom-server). `RenderAdapter.renderToString` permits a Promise;
17
+ // renderPage awaits it.
18
+ async renderToString(chain, props) {
19
+ const { renderToString } = await reactDomServer();
20
+ return renderToString(compose(chain, props));
16
21
  },
17
- renderToStream(chain, props) {
22
+ async renderToStream(chain, props) {
18
23
  // Resolves a Web `ReadableStream<Uint8Array>` once the shell is renderable; Suspense
19
24
  // boundaries stream as they resolve. No `bootstrapModules` — nifra injects the client entry in
20
25
  // the document tail. React's default `onError` logs to console.error (errors aren't swallowed).
26
+ const { renderToReadableStream } = await reactDomServer();
21
27
  return renderToReadableStream(compose(chain, props));
22
28
  },
23
29
  // React reconciles against the existing DOM on hydrate, so no per-document bootstrap
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA;;;;GAIG;AACH,OAAO,EAAE,cAAc,IAAI,mBAAmB,EAAE,sBAAsB,EAAE,MAAM,kBAAkB,CAAA;AAChG,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAEtC,6EAA6E;AAC7E,MAAM,CAAC,MAAM,YAAY,GAAkB;IACzC,gGAAgG;IAChG,kGAAkG;IAClG,oGAAoG;IACpG,mGAAmG;IACnG,cAAc,CAAC,KAAK,EAAE,KAAK;QACzB,OAAO,mBAAmB,CAAC,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,CAAA;IACnD,CAAC;IACD,cAAc,CAAC,KAAK,EAAE,KAAK;QACzB,qFAAqF;QACrF,+FAA+F;QAC/F,gGAAgG;QAChG,OAAO,sBAAsB,CAAC,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,CAAA;IACtD,CAAC;IACD,qFAAqF;IACrF,sFAAsF;IACtF,aAAa;QACX,OAAO,EAAE,CAAA;IACX,CAAC;CACF,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA;;;;GAIG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AACtC,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAA;AAEtD,6EAA6E;AAC7E,MAAM,CAAC,MAAM,YAAY,GAAkB;IACzC,gGAAgG;IAChG,kGAAkG;IAClG,oGAAoG;IACpG,mGAAmG;IACnG,EAAE;IACF,uGAAuG;IACvG,qGAAqG;IACrG,wBAAwB;IACxB,KAAK,CAAC,cAAc,CAAC,KAAK,EAAE,KAAK;QAC/B,MAAM,EAAE,cAAc,EAAE,GAAG,MAAM,cAAc,EAAE,CAAA;QACjD,OAAO,cAAc,CAAC,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,CAAA;IAC9C,CAAC;IACD,KAAK,CAAC,cAAc,CAAC,KAAK,EAAE,KAAK;QAC/B,qFAAqF;QACrF,+FAA+F;QAC/F,gGAAgG;QAChG,MAAM,EAAE,sBAAsB,EAAE,GAAG,MAAM,cAAc,EAAE,CAAA;QACzD,OAAO,sBAAsB,CAAC,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,CAAA;IACtD,CAAC;IACD,qFAAqF;IACrF,sFAAsF;IACtF,aAAa;QACX,OAAO,EAAE,CAAA;IACX,CAAC;CACF,CAAA"}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Resolve `react-dom/server` so it shares ONE React core with the route components — the fix for the
3
+ * dual-React SSR crash (`resolveDispatcher().useState` is null / "Invalid hook call: mismatching versions
4
+ * of React and the renderer").
5
+ *
6
+ * WHY this exists: under Bun **runtime** SSR (`nifra dev`, `nifra start`, `nifra_render`, all in-process),
7
+ * a static `import "react-dom/server"` in this adapter is resolved by Bun from THIS package's own
8
+ * (symlinked) node_modules — which can be a DIFFERENT physical `react` than the one the consumer app's
9
+ * route components import. Two React cores → two hook dispatchers → the renderer's dispatcher is the wrong
10
+ * (or null) one → the crash. Resolving `react-dom/server` from the consumer **app root** instead makes
11
+ * react-dom pull the app's `react` transitively, matching the components' `react`: a single core, a single
12
+ * dispatcher, no crash. (Empirically verified against a two-copy install fixture — see
13
+ * test/dual-react.test.ts.)
14
+ *
15
+ * Guarding precisely so the PRODUCTION/BUILT path is untouched: when `Bun.resolveSync` is unavailable
16
+ * (Node / Deno / Cloudflare / Vercel / any bundled output), the build has already bundled+deduped a single
17
+ * `react-dom` (buildServer's `reactDedupePlugin` pins `react`), so the static `import` is correct AND is
18
+ * the only thing that works without a Bun resolver. We fall back to it there. The app-root resolution runs
19
+ * ONLY under the Bun runtime, where the duplication can occur and `Bun.resolveSync` exists.
20
+ */
21
+ import type { ReactNode } from "react";
22
+ /** The slice of `react-dom/server` this adapter uses. Typed locally so the dynamic import (which Bun
23
+ * resolves to an absolute path string) stays strict — no `any` crosses the boundary. */
24
+ export interface ReactDomServer {
25
+ renderToString(node: ReactNode): string;
26
+ renderToReadableStream(node: ReactNode): Promise<ReadableStream<Uint8Array>>;
27
+ }
28
+ /**
29
+ * Get `react-dom/server` bound to the consumer app's React. Cached after the first call. Under the Bun
30
+ * runtime, dynamically imports the copy resolved from the app root; otherwise (built/bundled, or a non-Bun
31
+ * host) loads the statically-bundled `react-dom/server`.
32
+ */
33
+ export declare function reactDomServer(): Promise<ReactDomServer>;
34
+ /** A `Bun.resolveSync`-shaped function (specifier, from) → absolute path. */
35
+ type ResolveSync = (specifier: string, from: string) => string;
36
+ /**
37
+ * Load `react-dom/server`, preferring the app-root-resolved copy under the Bun runtime. Exported for unit
38
+ * tests: `resolve` defaults to the ambient `Bun.resolveSync` (undefined on non-Bun hosts), and a test can
39
+ * inject a stub that succeeds (re-root branch) or throws (fallback branch) to cover both deterministically
40
+ * without depending on the machine's node_modules layout.
41
+ */
42
+ export declare function loadReactDomServer(resolve?: ResolveSync | undefined): Promise<ReactDomServer>;
43
+ export {};
44
+ //# sourceMappingURL=react-dom-server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"react-dom-server.d.ts","sourceRoot":"","sources":["../src/react-dom-server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAEtC;wFACwF;AACxF,MAAM,WAAW,cAAc;IAC7B,cAAc,CAAC,IAAI,EAAE,SAAS,GAAG,MAAM,CAAA;IACvC,sBAAsB,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,cAAc,CAAC,UAAU,CAAC,CAAC,CAAA;CAC7E;AA4BD;;;;GAIG;AACH,wBAAgB,cAAc,IAAI,OAAO,CAAC,cAAc,CAAC,CAIxD;AAED,6EAA6E;AAC7E,KAAK,WAAW,GAAG,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;AAE9D;;;;;GAKG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,GAAE,WAAW,GAAG,SAA2B,GACjD,OAAO,CAAC,cAAc,CAAC,CAiBzB"}
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Resolve `react-dom/server` so it shares ONE React core with the route components — the fix for the
3
+ * dual-React SSR crash (`resolveDispatcher().useState` is null / "Invalid hook call: mismatching versions
4
+ * of React and the renderer").
5
+ *
6
+ * WHY this exists: under Bun **runtime** SSR (`nifra dev`, `nifra start`, `nifra_render`, all in-process),
7
+ * a static `import "react-dom/server"` in this adapter is resolved by Bun from THIS package's own
8
+ * (symlinked) node_modules — which can be a DIFFERENT physical `react` than the one the consumer app's
9
+ * route components import. Two React cores → two hook dispatchers → the renderer's dispatcher is the wrong
10
+ * (or null) one → the crash. Resolving `react-dom/server` from the consumer **app root** instead makes
11
+ * react-dom pull the app's `react` transitively, matching the components' `react`: a single core, a single
12
+ * dispatcher, no crash. (Empirically verified against a two-copy install fixture — see
13
+ * test/dual-react.test.ts.)
14
+ *
15
+ * Guarding precisely so the PRODUCTION/BUILT path is untouched: when `Bun.resolveSync` is unavailable
16
+ * (Node / Deno / Cloudflare / Vercel / any bundled output), the build has already bundled+deduped a single
17
+ * `react-dom` (buildServer's `reactDedupePlugin` pins `react`), so the static `import` is correct AND is
18
+ * the only thing that works without a Bun resolver. We fall back to it there. The app-root resolution runs
19
+ * ONLY under the Bun runtime, where the duplication can occur and `Bun.resolveSync` exists.
20
+ */
21
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
22
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
23
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
24
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
25
+ });
26
+ }
27
+ return path;
28
+ };
29
+ const bunResolver = globalThis.Bun;
30
+ const hasBunResolveSync = typeof bunResolver?.resolveSync === "function";
31
+ // Resolve once and cache: the module identity is stable for the process lifetime, and re-resolving per
32
+ // render would pay the import cost on every page. A Promise is cached (not the module) so concurrent
33
+ // first renders share a single in-flight import rather than racing N imports.
34
+ let cached;
35
+ /**
36
+ * The consumer app root used to re-root `react-dom/server`. `nifra dev` / `nifra start` run with the CLI's
37
+ * `process.cwd()` set to the app directory, and `nifra_render`'s subprocess `process.chdir`es to the app
38
+ * dir before SSR (see packages/cli/src/mcp-render.ts), so `process.cwd()` is the app root on every Bun
39
+ * runtime SSR path. Read lazily (not at module load) so a host that `chdir`s after import still sees the
40
+ * right root on first render.
41
+ */
42
+ function appRoot() {
43
+ return process.cwd();
44
+ }
45
+ /**
46
+ * Get `react-dom/server` bound to the consumer app's React. Cached after the first call. Under the Bun
47
+ * runtime, dynamically imports the copy resolved from the app root; otherwise (built/bundled, or a non-Bun
48
+ * host) loads the statically-bundled `react-dom/server`.
49
+ */
50
+ export function reactDomServer() {
51
+ if (cached !== undefined)
52
+ return cached;
53
+ cached = loadReactDomServer();
54
+ return cached;
55
+ }
56
+ /**
57
+ * Load `react-dom/server`, preferring the app-root-resolved copy under the Bun runtime. Exported for unit
58
+ * tests: `resolve` defaults to the ambient `Bun.resolveSync` (undefined on non-Bun hosts), and a test can
59
+ * inject a stub that succeeds (re-root branch) or throws (fallback branch) to cover both deterministically
60
+ * without depending on the machine's node_modules layout.
61
+ */
62
+ export async function loadReactDomServer(resolve = bunResolverFn()) {
63
+ if (resolve !== undefined) {
64
+ // Bun runtime SSR: re-root to the app's copy so react-dom shares the components' React. `react-dom`
65
+ // is a PEER dependency of this adapter, so a correct install puts it at the app root and this
66
+ // resolves. If it somehow doesn't (an unusual nested layout), fall through to the bundled specifier
67
+ // rather than crashing — degraded (possible duplicate) but never a hard failure.
68
+ try {
69
+ const resolved = resolve("react-dom/server", appRoot());
70
+ return (await import(__rewriteRelativeImportExtension(resolved)));
71
+ }
72
+ catch {
73
+ // App-root resolution failed; use the specifier resolved from this module's own location below.
74
+ }
75
+ }
76
+ // Built bundle / Node / Deno / edge (or the Bun-resolve fallback above): the static import is
77
+ // bundled+deduped (or is the only react-dom present). A bare-specifier dynamic import here lets the
78
+ // bundler include it (it's a constant), and at runtime resolves the copy visible to this package.
79
+ return (await import("react-dom/server"));
80
+ }
81
+ /** The ambient `Bun.resolveSync`, or undefined on a non-Bun host (where the static import is the only path). */
82
+ function bunResolverFn() {
83
+ return hasBunResolveSync && bunResolver?.resolveSync !== undefined
84
+ ? bunResolver.resolveSync.bind(bunResolver)
85
+ : undefined;
86
+ }
87
+ //# sourceMappingURL=react-dom-server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"react-dom-server.js","sourceRoot":"","sources":["../src/react-dom-server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;;;;;;;;;AAkBH,MAAM,WAAW,GAAI,UAA6C,CAAC,GAAG,CAAA;AACtE,MAAM,iBAAiB,GAAG,OAAO,WAAW,EAAE,WAAW,KAAK,UAAU,CAAA;AAExE,uGAAuG;AACvG,qGAAqG;AACrG,8EAA8E;AAC9E,IAAI,MAA2C,CAAA;AAE/C;;;;;;GAMG;AACH,SAAS,OAAO;IACd,OAAO,OAAO,CAAC,GAAG,EAAE,CAAA;AACtB,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,cAAc;IAC5B,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,MAAM,CAAA;IACvC,MAAM,GAAG,kBAAkB,EAAE,CAAA;IAC7B,OAAO,MAAM,CAAA;AACf,CAAC;AAKD;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CACtC,UAAmC,aAAa,EAAE;IAElD,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;QAC1B,oGAAoG;QACpG,8FAA8F;QAC9F,oGAAoG;QACpG,iFAAiF;QACjF,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,OAAO,CAAC,kBAAkB,EAAE,OAAO,EAAE,CAAC,CAAA;YACvD,OAAO,CAAC,MAAM,MAAM,kCAAC,QAAQ,EAAC,CAAmB,CAAA;QACnD,CAAC;QAAC,MAAM,CAAC;YACP,gGAAgG;QAClG,CAAC;IACH,CAAC;IACD,8FAA8F;IAC9F,oGAAoG;IACpG,kGAAkG;IAClG,OAAO,CAAC,MAAM,MAAM,CAAC,kBAAkB,CAAC,CAAmB,CAAA;AAC7D,CAAC;AAED,gHAAgH;AAChH,SAAS,aAAa;IACpB,OAAO,iBAAiB,IAAI,WAAW,EAAE,WAAW,KAAK,SAAS;QAChE,CAAC,CAAC,WAAW,CAAC,WAAW,CAAC,IAAI,CAAC,WAAW,CAAC;QAC3C,CAAC,CAAC,SAAS,CAAA;AACf,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/web-react",
3
- "version": "0.1.0-alpha.1",
3
+ "version": "0.1.0-alpha.3",
4
4
  "description": "React render adapter for @nifrajs/web — SSR + hydration (Bun-native JSX, no Babel).",
5
5
  "keywords": [
6
6
  "bun",
@@ -75,9 +75,9 @@
75
75
  "access": "public"
76
76
  },
77
77
  "dependencies": {
78
- "@nifrajs/i18n": "workspace:*",
79
- "@nifrajs/image": "workspace:*",
80
- "@nifrajs/web": "workspace:*"
78
+ "@nifrajs/i18n": "0.1.0-alpha.2",
79
+ "@nifrajs/image": "0.1.0-alpha.2",
80
+ "@nifrajs/web": "0.1.0-alpha.3"
81
81
  },
82
82
  "peerDependencies": {
83
83
  "react": "^19.0.0",
package/src/index.ts CHANGED
@@ -4,8 +4,8 @@ import type { RenderAdapter } from "@nifrajs/web"
4
4
  * `renderToReadableStream`; the layout-chain fold is in `./compose`. Client hydration lives in
5
5
  * `@nifrajs/web-react/client`. React's JSX is Bun-native — no build plugin needed.
6
6
  */
7
- import { renderToString as reactRenderToString, renderToReadableStream } from "react-dom/server"
8
7
  import { compose } from "./compose.ts"
8
+ import { reactDomServer } from "./react-dom-server.ts"
9
9
 
10
10
  /** The React server render adapter — pass to @nifrajs/web's `renderPage`. */
11
11
  export const reactAdapter: RenderAdapter = {
@@ -13,13 +13,19 @@ export const reactAdapter: RenderAdapter = {
13
13
  // `renderToString` emits the same hydration-compatible markup as the Fizz stream for non-Suspense
14
14
  // content and is markedly cheaper than `renderToReadableStream` — most visibly on Bun, where Fizz's
15
15
  // streaming machinery is the heaviest. A page that defer()s/Suspends takes `renderToStream` below.
16
- renderToString(chain, props) {
17
- return reactRenderToString(compose(chain, props))
16
+ //
17
+ // Async because the `react-dom/server` module is resolved lazily (from the consumer app's node_modules
18
+ // under Bun runtime SSR — see ./react-dom-server). `RenderAdapter.renderToString` permits a Promise;
19
+ // renderPage awaits it.
20
+ async renderToString(chain, props) {
21
+ const { renderToString } = await reactDomServer()
22
+ return renderToString(compose(chain, props))
18
23
  },
19
- renderToStream(chain, props) {
24
+ async renderToStream(chain, props) {
20
25
  // Resolves a Web `ReadableStream<Uint8Array>` once the shell is renderable; Suspense
21
26
  // boundaries stream as they resolve. No `bootstrapModules` — nifra injects the client entry in
22
27
  // the document tail. React's default `onError` logs to console.error (errors aren't swallowed).
28
+ const { renderToReadableStream } = await reactDomServer()
23
29
  return renderToReadableStream(compose(chain, props))
24
30
  },
25
31
  // React reconciles against the existing DOM on hydrate, so no per-document bootstrap
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Resolve `react-dom/server` so it shares ONE React core with the route components — the fix for the
3
+ * dual-React SSR crash (`resolveDispatcher().useState` is null / "Invalid hook call: mismatching versions
4
+ * of React and the renderer").
5
+ *
6
+ * WHY this exists: under Bun **runtime** SSR (`nifra dev`, `nifra start`, `nifra_render`, all in-process),
7
+ * a static `import "react-dom/server"` in this adapter is resolved by Bun from THIS package's own
8
+ * (symlinked) node_modules — which can be a DIFFERENT physical `react` than the one the consumer app's
9
+ * route components import. Two React cores → two hook dispatchers → the renderer's dispatcher is the wrong
10
+ * (or null) one → the crash. Resolving `react-dom/server` from the consumer **app root** instead makes
11
+ * react-dom pull the app's `react` transitively, matching the components' `react`: a single core, a single
12
+ * dispatcher, no crash. (Empirically verified against a two-copy install fixture — see
13
+ * test/dual-react.test.ts.)
14
+ *
15
+ * Guarding precisely so the PRODUCTION/BUILT path is untouched: when `Bun.resolveSync` is unavailable
16
+ * (Node / Deno / Cloudflare / Vercel / any bundled output), the build has already bundled+deduped a single
17
+ * `react-dom` (buildServer's `reactDedupePlugin` pins `react`), so the static `import` is correct AND is
18
+ * the only thing that works without a Bun resolver. We fall back to it there. The app-root resolution runs
19
+ * ONLY under the Bun runtime, where the duplication can occur and `Bun.resolveSync` exists.
20
+ */
21
+
22
+ import type { ReactNode } from "react"
23
+
24
+ /** The slice of `react-dom/server` this adapter uses. Typed locally so the dynamic import (which Bun
25
+ * resolves to an absolute path string) stays strict — no `any` crosses the boundary. */
26
+ export interface ReactDomServer {
27
+ renderToString(node: ReactNode): string
28
+ renderToReadableStream(node: ReactNode): Promise<ReadableStream<Uint8Array>>
29
+ }
30
+
31
+ // `globalThis.Bun` isn't in the ambient lib types; narrow exactly the one method we need so we never
32
+ // reach for `any`. `resolveSync(specifier, from)` returns the absolute path the specifier resolves to
33
+ // when required from `from` — Bun's runtime resolver, the only lever that re-roots a BARE specifier
34
+ // (a runtime `Bun.plugin` onResolve does NOT fire for bare specifiers like `react-dom/server`, verified).
35
+ interface BunResolver {
36
+ resolveSync(specifier: string, from: string): string
37
+ }
38
+ const bunResolver = (globalThis as { Bun?: Partial<BunResolver> }).Bun
39
+ const hasBunResolveSync = typeof bunResolver?.resolveSync === "function"
40
+
41
+ // Resolve once and cache: the module identity is stable for the process lifetime, and re-resolving per
42
+ // render would pay the import cost on every page. A Promise is cached (not the module) so concurrent
43
+ // first renders share a single in-flight import rather than racing N imports.
44
+ let cached: Promise<ReactDomServer> | undefined
45
+
46
+ /**
47
+ * The consumer app root used to re-root `react-dom/server`. `nifra dev` / `nifra start` run with the CLI's
48
+ * `process.cwd()` set to the app directory, and `nifra_render`'s subprocess `process.chdir`es to the app
49
+ * dir before SSR (see packages/cli/src/mcp-render.ts), so `process.cwd()` is the app root on every Bun
50
+ * runtime SSR path. Read lazily (not at module load) so a host that `chdir`s after import still sees the
51
+ * right root on first render.
52
+ */
53
+ function appRoot(): string {
54
+ return process.cwd()
55
+ }
56
+
57
+ /**
58
+ * Get `react-dom/server` bound to the consumer app's React. Cached after the first call. Under the Bun
59
+ * runtime, dynamically imports the copy resolved from the app root; otherwise (built/bundled, or a non-Bun
60
+ * host) loads the statically-bundled `react-dom/server`.
61
+ */
62
+ export function reactDomServer(): Promise<ReactDomServer> {
63
+ if (cached !== undefined) return cached
64
+ cached = loadReactDomServer()
65
+ return cached
66
+ }
67
+
68
+ /** A `Bun.resolveSync`-shaped function (specifier, from) → absolute path. */
69
+ type ResolveSync = (specifier: string, from: string) => string
70
+
71
+ /**
72
+ * Load `react-dom/server`, preferring the app-root-resolved copy under the Bun runtime. Exported for unit
73
+ * tests: `resolve` defaults to the ambient `Bun.resolveSync` (undefined on non-Bun hosts), and a test can
74
+ * inject a stub that succeeds (re-root branch) or throws (fallback branch) to cover both deterministically
75
+ * without depending on the machine's node_modules layout.
76
+ */
77
+ export async function loadReactDomServer(
78
+ resolve: ResolveSync | undefined = bunResolverFn(),
79
+ ): Promise<ReactDomServer> {
80
+ if (resolve !== undefined) {
81
+ // Bun runtime SSR: re-root to the app's copy so react-dom shares the components' React. `react-dom`
82
+ // is a PEER dependency of this adapter, so a correct install puts it at the app root and this
83
+ // resolves. If it somehow doesn't (an unusual nested layout), fall through to the bundled specifier
84
+ // rather than crashing — degraded (possible duplicate) but never a hard failure.
85
+ try {
86
+ const resolved = resolve("react-dom/server", appRoot())
87
+ return (await import(resolved)) as ReactDomServer
88
+ } catch {
89
+ // App-root resolution failed; use the specifier resolved from this module's own location below.
90
+ }
91
+ }
92
+ // Built bundle / Node / Deno / edge (or the Bun-resolve fallback above): the static import is
93
+ // bundled+deduped (or is the only react-dom present). A bare-specifier dynamic import here lets the
94
+ // bundler include it (it's a constant), and at runtime resolves the copy visible to this package.
95
+ return (await import("react-dom/server")) as ReactDomServer
96
+ }
97
+
98
+ /** The ambient `Bun.resolveSync`, or undefined on a non-Bun host (where the static import is the only path). */
99
+ function bunResolverFn(): ResolveSync | undefined {
100
+ return hasBunResolveSync && bunResolver?.resolveSync !== undefined
101
+ ? bunResolver.resolveSync.bind(bunResolver)
102
+ : undefined
103
+ }