diff --git a/docs/superpowers/plans/2026-06-26-effectts-plugin-adoption.md b/docs/superpowers/plans/2026-06-26-effectts-plugin-adoption.md new file mode 100644 index 0000000..c82be1f --- /dev/null +++ b/docs/superpowers/plans/2026-06-26-effectts-plugin-adoption.md @@ -0,0 +1,132 @@ +# EffectTS Plugin Adoption Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the first internal Effect-backed scheduling seam to the route transform executor while preserving public Promise APIs and existing React Router generated output. + +**Architecture:** Effect is introduced as an internal runtime dependency. The first migration keeps the `RouteTransformExecutor` interface stable and uses Effect behind `close()` cleanup and Promise boundaries, leaving worker-side code untouched. + +**Tech Stack:** TypeScript, Rsbuild/Rspack, Rstest, pnpm, Effect 3.x. + +--- + +## Files + +- Create: `docs/superpowers/specs/2026-06-26-effectts-plugin-adoption-design.md` +- Create: `docs/superpowers/plans/2026-06-26-effectts-plugin-adoption.md` +- Create: `src/effect-runtime.ts` +- Modify: `package.json` +- Modify: `pnpm-lock.yaml` +- Modify: `src/parallel-route-transforms.ts` +- Modify: `tests/parallel-route-transforms.test.ts` + +## Task 1: Add Design And Plan Artifacts + +- [ ] **Step 1: Write the design spec** + +Create `docs/superpowers/specs/2026-06-26-effectts-plugin-adoption-design.md` with the architecture, non-goals, rollout, and test plan. + +- [ ] **Step 2: Write this implementation plan** + +Create `docs/superpowers/plans/2026-06-26-effectts-plugin-adoption.md` with task-by-task implementation steps. + +- [ ] **Step 3: Commit docs** + +Run: + +```bash +git add docs/superpowers/specs/2026-06-26-effectts-plugin-adoption-design.md docs/superpowers/plans/2026-06-26-effectts-plugin-adoption.md +git commit -m "docs: plan effect adoption" +``` + +## Task 2: Add Lifecycle Tests + +- [ ] **Step 1: Add tests before production code** + +Add tests to `tests/parallel-route-transforms.test.ts` covering: + +- `close()` rejects an in-flight worker task. +- `close()` is idempotent and `run()` after close falls back inline. +- A `postMessage` failure clears the source cache so the next request sends full source. + +- [ ] **Step 2: Run focused tests and verify the new postMessage test fails** + +Run: + +```bash +corepack pnpm exec rstest run -c ./rstest.config.ts tests/parallel-route-transforms.test.ts +``` + +Expected: the source-cache clearing test fails until the worker factory test seam and implementation are added. + +## Task 3: Add Effect Dependency And Boundary Helper + +- [ ] **Step 1: Add Effect dependency** + +Run: + +```bash +corepack pnpm add effect +``` + +- [ ] **Step 2: Add `src/effect-runtime.ts`** + +Create a small internal helper that exports: + +```ts +import { Effect } from 'effect'; + +export const runPluginEffect = ( + effect: Effect.Effect +): Promise => Effect.runPromise(effect); + +export const normalizeEffectError = (cause: unknown): Error => + cause instanceof Error ? cause : new Error(String(cause)); +``` + +## Task 4: Migrate Route Transform Executor Cleanup + +- [ ] **Step 1: Add a worker factory test seam** + +Keep `createRouteTransformExecutor()` unchanged for callers. Add an internal exported test helper that accepts a worker factory and delegates to the same implementation. + +- [ ] **Step 2: Use Effect inside `close()`** + +Replace manual `Promise.all(workers.map(...terminate...))` cleanup with an Effect program that rejects pending tasks, clears maps, terminates workers, and returns through `runPluginEffect`. + +- [ ] **Step 3: Preserve behavior** + +Keep inline fallback after close, worker startup fallback, transform error propagation, and source-cache invalidation on `postMessage` failure. + +## Task 5: Verify And Publish + +- [ ] **Step 1: Run focused tests** + +```bash +corepack pnpm exec rstest run -c ./rstest.config.ts tests/parallel-route-transforms.test.ts +``` + +- [ ] **Step 2: Run full verification** + +```bash +corepack pnpm run test:core +corepack pnpm exec tsc --noEmit +corepack pnpm run format:check +corepack pnpm run build +corepack pnpm run test:package-interop +git diff --check +``` + +- [ ] **Step 3: Commit implementation** + +```bash +git add package.json pnpm-lock.yaml src/effect-runtime.ts src/parallel-route-transforms.ts tests/parallel-route-transforms.test.ts +git commit -m "feat: use effect for transform executor cleanup" +``` + +- [ ] **Step 4: Push and open a draft PR** + +```bash +git push -u origin codex/effectts-scheduling +gh pr create --draft --base codex/parallel-dev-compilers --head codex/effectts-scheduling +``` diff --git a/docs/superpowers/specs/2026-06-26-effectts-plugin-adoption-design.md b/docs/superpowers/specs/2026-06-26-effectts-plugin-adoption-design.md new file mode 100644 index 0000000..9e891e2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-26-effectts-plugin-adoption-design.md @@ -0,0 +1,78 @@ +# EffectTS Plugin Adoption Design + +## Goal + +Adopt Effect internally in `rsbuild-plugin-react-router` to make async scheduling, lifecycle cleanup, cancellation, retry, and error propagation easier to reason about without changing public plugin APIs or generated React Router framework code. + +## Scope + +Effect is allowed inside the plugin implementation and build tooling paths. It must not be emitted into generated React Router framework modules, templates, route code, or public plugin configuration types. + +The first implementation seam is the route transform executor in `src/parallel-route-transforms.ts`. It already exposes a Promise-shaped `RouteTransformExecutor` with `run()` and `close()`, which makes it possible to migrate internal scheduling while keeping callers unchanged. + +Later seams can build on the same internal patterns: + +- `src/dev-runtime-controller.ts` and `src/dev-generation.ts` for compiler attempt coordination, invalidation, retry-node scheduling, and stale evaluation cancellation. +- `src/prerender-build.ts` for bounded prerender concurrency and fail-fast semantics. +- Small config/preset hooks only when structured error aggregation is clearly useful. + +## Non-Goals + +- Do not expose Effect types through `PluginOptions`, `RouteTransformExecutor`, or `loadReactRouterServerBuild`. +- Do not import Effect from `src/templates/*`. +- Do not import Effect from generated virtual React Router modules. +- Do not migrate `src/parallel-route-transform-worker.ts` in the first step; keep worker startup and per-task payloads lean. +- Do not rewrite the whole plugin setup function in one pass. + +## Architecture + +Add `effect` as a runtime dependency and introduce a small internal adapter module for converting Effect computations back to Promises at Rsbuild/plugin boundaries. The plugin remains Promise-native externally, but internal resources can use Effect concepts where they simplify state: + +- `Effect.tryPromise` for worker termination and Promise-returning boundaries. +- `Effect.runPromise` for preserving the existing async API surface. +- `Effect.all` for deterministic close cleanup over worker resources. +- Typed internal helpers for normalizing unknown causes into `Error`. + +The first migration keeps the existing `ParallelRouteTransformExecutor` class and replaces manual Promise orchestration in `close()` with Effect-backed cleanup. That is intentionally conservative: it proves dependency, build, type, and test compatibility before moving more subtle lifecycle code. + +## Route Transform Executor Behavior To Preserve + +- `parallelTransforms: false` runs all tasks inline. +- Invalid explicit worker counts throw the same validation error. +- Worker startup errors disable workers and fall back to inline execution for later tasks. +- Route transform task errors propagate normally. +- `close()` is idempotent. +- `close()` rejects in-flight worker tasks before terminating workers. +- `run()` after `close()` executes inline. +- A failed `postMessage` clears that worker's source cache entry so the next request for the same route sends full source again. + +## Test Plan + +Add focused tests in `tests/parallel-route-transforms.test.ts` for executor lifecycle behavior before changing implementation: + +- `close()` can be called more than once and still allows later inline execution. +- `close()` rejects in-flight tasks. +- A `postMessage` failure clears source cache and the next request sends full code. + +Keep existing tests green: + +- `corepack pnpm run test:core` +- `corepack pnpm exec tsc --noEmit` +- `corepack pnpm run format:check` +- `corepack pnpm run build` +- `corepack pnpm run test:package-interop` + +## Rollout + +1. Commit this spec and implementation plan. +2. Add `effect` and the internal Promise boundary helper. +3. Add failing lifecycle tests around route transform executor cleanup. +4. Migrate route transform executor cleanup to Effect internally. +5. Verify the existing suite and package interop. +6. Open a draft PR stacked on the parallel dev compiler PR. + +## Risks + +Effect adds a real runtime dependency. That is acceptable only if it stays internal and removes enough scheduling complexity over the staged rollout. The first step is intentionally small so the PR proves integration cost before attempting dev-runtime migration. + +The dev runtime remains the highest-value target, but it has subtle Rsbuild timing behavior. It should be migrated only after the route transform executor establishes a tested internal Effect pattern. diff --git a/package.json b/package.json index 3f54284..7cec72f 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@react-router/node": "^7.13.0", "@remix-run/node-fetch-server": "^0.13.0", "@rspack/plugin-react-refresh": "^2.0.2", + "effect": "^3.21.4", "execa": "^9.6.1", "fs-extra": "11.3.3", "isbot": "5.1.34", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0eb21a..2e63f7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@rspack/plugin-react-refresh': specifier: ^2.0.2 version: 2.0.2(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(react-refresh@0.18.0) + effect: + specifier: ^3.21.4 + version: 3.21.4 execa: specifier: ^9.6.1 version: 9.6.1 @@ -290,7 +293,7 @@ importers: version: 1.58.0 '@react-router/dev': specifier: ^7.13.0 - version: 7.18.0(@react-router/serve@7.18.0(react-router@7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@5.9.3))(@types/node@25.0.10)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(react-router@7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0))(wrangler@4.105.0(@cloudflare/workers-types@4.20260628.1)) + version: 7.18.0(@react-router/serve@7.18.0(react-router@7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@5.9.3))(@types/node@25.9.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(react-router@7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.9.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0))(wrangler@4.105.0(@cloudflare/workers-types@4.20260628.1)) '@rsbuild/core': specifier: 2.1.0 version: 2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) @@ -597,7 +600,7 @@ importers: version: 3.0.2(remix-auth@4.2.0) remix-utils: specifier: 9.0.0 - version: 9.0.0(@oslojs/crypto@1.0.1)(@oslojs/encoding@1.1.0)(intl-parse-accept-language@1.0.0)(react-router@7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 9.0.0(@oslojs/crypto@1.0.1)(@oslojs/encoding@1.1.0)(@standard-schema/spec@1.1.0)(intl-parse-accept-language@1.0.0)(react-router@7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) rsbuild-plugin-react-router: specifier: workspace:* version: link:../.. @@ -979,7 +982,7 @@ importers: version: 3.0.2(remix-auth@4.2.0) remix-utils: specifier: 9.0.0 - version: 9.0.0(@oslojs/crypto@1.0.1)(@oslojs/encoding@1.1.0)(intl-parse-accept-language@1.0.0)(react-router@7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 9.0.0(@oslojs/crypto@1.0.1)(@oslojs/encoding@1.1.0)(@standard-schema/spec@1.1.0)(intl-parse-accept-language@1.0.0)(react-router@7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) rsbuild-plugin-react-router: specifier: workspace:* version: link:../../.. @@ -1343,7 +1346,7 @@ importers: version: 3.0.2(remix-auth@4.2.0) remix-utils: specifier: 9.0.0 - version: 9.0.0(@oslojs/crypto@1.0.1)(@oslojs/encoding@1.1.0)(intl-parse-accept-language@1.0.0)(react-router@7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 9.0.0(@oslojs/crypto@1.0.1)(@oslojs/encoding@1.1.0)(@standard-schema/spec@1.1.0)(intl-parse-accept-language@1.0.0)(react-router@7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) rsbuild-plugin-react-router: specifier: workspace:* version: link:../../.. @@ -4824,6 +4827,9 @@ packages: '@speed-highlight/core@1.2.17': resolution: {integrity: sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} @@ -5099,6 +5105,9 @@ packages: '@types/node@25.0.10': resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==} + '@types/node@25.9.4': + resolution: {integrity: sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==} + '@types/pegjs@0.10.6': resolution: {integrity: sha512-eLYXDbZWXh2uxf+w8sXS8d6KSoXTswfps6fvCUuVAGN8eRpfe7h9eSRydxiSJvo9Bf+GzifsDOr9TMQlmJdmkw==} @@ -6380,6 +6389,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + effect@3.21.4: + resolution: {integrity: sha512-B89v/xSgPbl1J2Ai2u18jxq3odpFauU1rC6/eSs4FeNHi72kwKdJp12VGigvRV2lK+kRnx+OOz41XV8guZd4gQ==} + electron-to-chromium@1.5.380: resolution: {integrity: sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA==} @@ -6697,6 +6709,10 @@ packages: extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -8455,6 +8471,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + qrcode@1.5.4: resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} engines: {node: '>=10.13.0'} @@ -9495,6 +9514,9 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici@7.24.7: resolution: {integrity: sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==} engines: {node: '>=20.18.1'} @@ -12219,6 +12241,57 @@ snapshots: - tsx - yaml + '@react-router/dev@7.18.0(@react-router/serve@7.18.0(react-router@7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@5.9.3))(@types/node@25.9.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(react-router@7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.9.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0))(wrangler@4.105.0(@cloudflare/workers-types@4.20260628.1))': + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@react-router/node': 7.18.0(react-router@7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@5.9.3) + '@remix-run/node-fetch-server': 0.13.3 + arg: 5.0.2 + babel-dead-code-elimination: 1.0.12 + chokidar: 4.0.3 + dedent: 1.7.2 + es-module-lexer: 1.7.0 + exit-hook: 2.2.1 + isbot: 5.1.34 + jsesc: 3.0.2 + lodash: 4.18.1 + p-map: 7.0.4 + pathe: 1.1.2 + picocolors: 1.1.1 + pkg-types: 2.3.1 + prettier: 3.8.1 + react-refresh: 0.14.2 + react-router: 7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + semver: 7.8.5 + tinyglobby: 0.2.17 + valibot: 1.4.2(typescript@5.9.3) + vite: 7.3.1(@types/node@25.9.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0) + vite-node: 3.2.4(@types/node@25.9.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0) + optionalDependencies: + '@react-router/serve': 7.18.0(react-router@7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@5.9.3) + typescript: 5.9.3 + wrangler: 4.105.0(@cloudflare/workers-types@4.20260628.1) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + '@react-router/dev@8.0.1(react-router@7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0))(wrangler@4.105.0(@cloudflare/workers-types@4.20260628.1))': dependencies: '@babel/core': 7.29.7 @@ -13115,6 +13188,8 @@ snapshots: '@speed-highlight/core@1.2.17': {} + '@standard-schema/spec@1.1.0': {} + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -13431,6 +13506,11 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/node@25.9.4': + dependencies: + undici-types: 7.24.6 + optional: true + '@types/pegjs@0.10.6': {} '@types/pg-pool@2.0.7': @@ -14668,6 +14748,11 @@ snapshots: ee-first@1.1.1: {} + effect@3.21.4: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + electron-to-chromium@1.5.380: {} emoji-regex@8.0.0: {} @@ -15208,6 +15293,10 @@ snapshots: extendable-error@0.1.7: {} + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -16760,6 +16849,8 @@ snapshots: punycode@2.3.1: {} + pure-rand@6.1.0: {} + qrcode@1.5.4: dependencies: dijkstrajs: 1.0.3 @@ -17021,12 +17112,13 @@ snapshots: fs-extra: 11.3.3 minimatch: 10.2.5 - remix-utils@9.0.0(@oslojs/crypto@1.0.1)(@oslojs/encoding@1.1.0)(intl-parse-accept-language@1.0.0)(react-router@7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7): + remix-utils@9.0.0(@oslojs/crypto@1.0.1)(@oslojs/encoding@1.1.0)(@standard-schema/spec@1.1.0)(intl-parse-accept-language@1.0.0)(react-router@7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7): dependencies: type-fest: 4.41.0 optionalDependencies: '@oslojs/crypto': 1.0.1 '@oslojs/encoding': 1.1.0 + '@standard-schema/spec': 1.1.0 intl-parse-accept-language: 1.0.0 react: 19.2.7 react-router: 7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -17919,6 +18011,9 @@ snapshots: undici-types@7.16.0: {} + undici-types@7.24.6: + optional: true + undici@7.24.7: {} undici@7.28.0: {} @@ -18056,6 +18151,27 @@ snapshots: - tsx - yaml + vite-node@3.2.4(@types/node@25.9.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.1(@types/node@25.9.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)): dependencies: debug: 4.4.3 @@ -18085,6 +18201,25 @@ snapshots: terser: 5.48.0 tsx: 4.21.0 + vite@7.3.1(@types/node@25.9.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.16 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.9.4 + fsevents: 2.3.3 + jiti: 2.7.0 + less: 4.6.7 + lightningcss: 1.32.0 + sass: 1.100.0 + sass-embedded: 1.100.0 + terser: 5.48.0 + tsx: 4.21.0 + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 diff --git a/src/build-manifest.ts b/src/build-manifest.ts index 5c49bab..13e87a8 100644 --- a/src/build-manifest.ts +++ b/src/build-manifest.ts @@ -1,4 +1,6 @@ import { relative, resolve } from 'pathe'; +import { Effect } from 'effect'; +import { runPluginEffect, tryPluginPromise } from './effect-runtime.js'; import type { Config } from './react-router-config.js'; import type { Route } from './types.js'; @@ -57,11 +59,7 @@ const configRouteToBranchRoute = (route: Route) => ({ index: route.index, }); -export const getBuildManifest = async ({ - reactRouterConfig, - routes, - rootDirectory, -}: { +type GetBuildManifestOptions = { reactRouterConfig: Required< Pick< Config, @@ -71,80 +69,107 @@ export const getBuildManifest = async ({ Pick; routes: Record; rootDirectory: string; -}): Promise => { - const { - serverBundles, - appDirectory, - buildDirectory, - serverBuildFile, - future, - } = reactRouterConfig; - - if (!serverBundles) { - return { routes }; - } +}; - const rootRelativeRoutes = Object.fromEntries( - Object.entries(routes).map(([id, route]) => { - const filePath = resolve(appDirectory, route.file); - return [ - id, - { ...route, file: normalizePath(relative(rootDirectory, filePath)) }, - ]; - }) - ); +export const getBuildManifestEffect = ({ + reactRouterConfig, + routes, + rootDirectory, +}: GetBuildManifestOptions): Effect.Effect< + BuildManifest | undefined, + Error, + never +> => + Effect.gen(function* () { + const { + serverBundles, + appDirectory, + buildDirectory, + serverBuildFile, + future, + } = reactRouterConfig; + + if (!serverBundles) { + return { routes }; + } - const serverBuildDirectory = resolve(buildDirectory, 'server'); - - const buildManifest: BuildManifest = { - routes: rootRelativeRoutes, - serverBundles: {}, - routeIdToServerBundleId: {}, - }; - - await Promise.all( - getAddressableRoutes(routes).map(async route => { - const branch = getRouteBranch(routes, route.id); - const serverBundleId = await serverBundles({ - branch: branch.map(branchRoute => - configRouteToBranchRoute({ - ...branchRoute, - file: resolve(appDirectory, branchRoute.file), - }) - ), - }); - - if (typeof serverBundleId !== 'string') { - throw new Error('The "serverBundles" function must return a string'); - } + const rootRelativeRoutes = Object.fromEntries( + Object.entries(routes).map(([id, route]) => { + const filePath = resolve(appDirectory, route.file); + return [ + id, + { ...route, file: normalizePath(relative(rootDirectory, filePath)) }, + ]; + }) + ); + + const serverBuildDirectory = resolve(buildDirectory, 'server'); + + const buildManifest: BuildManifest = { + routes: rootRelativeRoutes, + serverBundles: {}, + routeIdToServerBundleId: {}, + }; - if (future?.v8_viteEnvironmentApi) { - if (!/^[a-zA-Z0-9_]+$/.test(serverBundleId)) { - throw new Error( - 'The "serverBundles" function must only return strings containing alphanumeric characters and underscores.' + yield* Effect.forEach( + getAddressableRoutes(routes), + route => + Effect.gen(function* () { + const branch = getRouteBranch(routes, route.id); + const serverBundleId = yield* tryPluginPromise(() => + serverBundles({ + branch: branch.map(branchRoute => + configRouteToBranchRoute({ + ...branchRoute, + file: resolve(appDirectory, branchRoute.file), + }) + ), + }) ); - } - } else if (!/^[a-zA-Z0-9-_]+$/.test(serverBundleId)) { - throw new Error( - 'The "serverBundles" function must only return strings containing alphanumeric characters, hyphens and underscores.' - ); - } - - buildManifest.routeIdToServerBundleId![route.id] = serverBundleId; - buildManifest.serverBundles![serverBundleId] ??= { - id: serverBundleId, - file: normalizePath( - relative( - rootDirectory, - resolve(serverBuildDirectory, serverBundleId, serverBuildFile) - ) - ), - }; - }) - ); - return buildManifest; -}; + if (typeof serverBundleId !== 'string') { + return yield* Effect.fail( + new Error('The "serverBundles" function must return a string') + ); + } + + if (future?.v8_viteEnvironmentApi) { + if (!/^[a-zA-Z0-9_]+$/.test(serverBundleId)) { + return yield* Effect.fail( + new Error( + 'The "serverBundles" function must only return strings containing alphanumeric characters and underscores.' + ) + ); + } + } else if (!/^[a-zA-Z0-9-_]+$/.test(serverBundleId)) { + return yield* Effect.fail( + new Error( + 'The "serverBundles" function must only return strings containing alphanumeric characters, hyphens and underscores.' + ) + ); + } + + buildManifest.routeIdToServerBundleId![route.id] = serverBundleId; + buildManifest.serverBundles![serverBundleId] ??= { + id: serverBundleId, + file: normalizePath( + relative( + rootDirectory, + resolve(serverBuildDirectory, serverBundleId, serverBuildFile) + ) + ), + }; + }), + { concurrency: 'unbounded', discard: true } + ); + + return buildManifest; + }); + +export const getBuildManifest = ( + options: GetBuildManifestOptions +): Promise => + runPluginEffect(getBuildManifestEffect(options)); export const getRoutesByServerBundleId = ( buildManifest: BuildManifest | undefined, diff --git a/src/concurrency.ts b/src/concurrency.ts index bf51f8e..87d9f8c 100644 --- a/src/concurrency.ts +++ b/src/concurrency.ts @@ -1,4 +1,6 @@ import { availableParallelism, cpus } from 'node:os'; +import { Effect } from 'effect'; +import { runPluginEffect, tryPluginPromise } from './effect-runtime.js'; const DEFAULT_RESERVED_CORES = 2; @@ -16,19 +18,13 @@ export const mapWithConcurrency = async ( concurrency: number, worker: (item: Item, index: number) => Promise ): Promise => { - const results = new Array(items.length); - let nextIndex = 0; const workerCount = Math.max(1, Math.min(concurrency, items.length)); - await Promise.all( - Array.from({ length: workerCount }, async () => { - while (true) { - const index = nextIndex++; - if (index >= items.length) { - return; - } - results[index] = await worker(items[index], index); - } - }) + + return runPluginEffect( + Effect.forEach( + items.map((item, index) => ({ item, index })), + ({ item, index }) => tryPluginPromise(() => worker(item, index)), + { concurrency: workerCount } + ) ); - return results; }; diff --git a/src/dev-generation.ts b/src/dev-generation.ts index 0c6075d..065b516 100644 --- a/src/dev-generation.ts +++ b/src/dev-generation.ts @@ -1,7 +1,8 @@ import type { RsbuildDevServer, Rspack } from '@rsbuild/core'; +import { Deferred as EffectDeferred, Effect } from 'effect'; import type { ServerBuild } from 'react-router'; import { - evaluateServerBuilds, + evaluateServerBuildsEffect, getEnvironmentStats, isSafeOneSidedChange, pinServerBuildsToManifests, @@ -15,6 +16,7 @@ import { type ReactRouterServerBuilds, type WebArtifact, } from './dev-runtime-artifacts.js'; +import { runPluginEffect } from './effect-runtime.js'; export { snapshotDevChangedFiles } from './dev-runtime-artifacts.js'; export type { @@ -26,12 +28,6 @@ export type { ReactRouterDevManifestSet, } from './dev-runtime-artifacts.js'; -type Deferred = { - promise: Promise; - resolve: (value: T) => void; - reject: (error: Error) => void; -}; - type CommittedGeneration = { buildsByEntryName: ReactRouterServerBuilds; webIdentity: DevCompilationIdentity; @@ -44,7 +40,7 @@ type RuntimeState = | { kind: 'starting'; attemptId: number; - readiness: Deferred; + readiness: EffectDeferred.Deferred; } | { kind: 'failed'; attemptId: number; error: Error } | { @@ -194,18 +190,10 @@ const hasOnlyCssAssetOwnershipChanges = ( }); }; -const createDeferred = (): Deferred => { - let resolve!: (value: T) => void; - let reject!: (error: Error) => void; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - // Compilation can fail before a request asks for the build. Observe the - // rejection now while returning the same promise to future callers. - void promise.catch(() => undefined); - return { promise, resolve, reject }; -}; +const createReadinessDeferred = (): EffectDeferred.Deferred< + CommittedGeneration, + Error +> => Effect.runSync(EffectDeferred.make()); export const createReactRouterDevRuntime = ({ server, @@ -219,7 +207,7 @@ export const createReactRouterDevRuntime = ({ let state: RuntimeState = { kind: 'starting', attemptId: 0, - readiness: createDeferred(), + readiness: createReadinessDeferred(), }; const manifestsByCompilation = new WeakMap< Rspack.Compilation, @@ -282,7 +270,7 @@ export const createReactRouterDevRuntime = ({ if (state.kind === 'starting') { const { readiness } = state; state = { kind: 'failed', attemptId, error }; - readiness.reject(error); + Effect.runSync(EffectDeferred.fail(readiness, error)); } else if (state.kind === 'ready') { state = { ...state, pendingAttemptId: null }; } @@ -301,7 +289,7 @@ export const createReactRouterDevRuntime = ({ if (state.kind === 'starting') { const { readiness } = state; state = { kind: 'ready', committed, pendingAttemptId: null }; - readiness.resolve(committed); + Effect.runSync(EffectDeferred.succeed(readiness, committed)); } else if (state.kind === 'ready') { state = { kind: 'ready', committed, pendingAttemptId: null }; } @@ -343,7 +331,7 @@ export const createReactRouterDevRuntime = ({ state = { kind: 'starting', attemptId, - readiness: createDeferred(), + readiness: createReadinessDeferred(), }; } else if (state.kind === 'starting') { state = { ...state, attemptId }; @@ -484,7 +472,9 @@ export const createReactRouterDevRuntime = ({ try { const buildsByEntryName = shouldEvaluateNode - ? await evaluateServerBuilds(server, buildPlan.entryNames) + ? await runPluginEffect( + evaluateServerBuildsEffect(server, buildPlan.entryNames) + ) : previous!.buildsByEntryName; if (!isCurrentAttempt(attemptId)) { return 'ignored'; @@ -552,11 +542,11 @@ export const createReactRouterDevRuntime = ({ return Promise.resolve(selectBuild(state.committed, entryName)); } if (state.kind === 'starting') { - const selected = state.readiness.promise.then(generation => - selectBuild(generation, entryName) + const selected = runPluginEffect( + EffectDeferred.await(state.readiness).pipe( + Effect.map(generation => selectBuild(generation, entryName)) + ) ); - // Compilation may fail before the request awaiting this selection has - // a chance to attach its own rejection handler. void selected.catch(() => undefined); return selected; } @@ -573,7 +563,7 @@ export const createReactRouterDevRuntime = ({ '[rsbuild-plugin-react-router] The development server closed before a React Router build was ready.' ); if (state.kind === 'starting') { - state.readiness.reject(closeError); + Effect.runSync(EffectDeferred.fail(state.readiness, closeError)); } state = { kind: 'closed', error: closeError }; }, diff --git a/src/dev-runtime-artifacts.ts b/src/dev-runtime-artifacts.ts index 7e3a4a2..2f3ec21 100644 --- a/src/dev-runtime-artifacts.ts +++ b/src/dev-runtime-artifacts.ts @@ -1,8 +1,14 @@ import { isAbsolute, relative } from 'node:path'; import type { RsbuildDevServer, Rspack } from '@rsbuild/core'; +import { Effect } from 'effect'; import type { ServerBuild } from 'react-router'; import type { ReactRouterManifestForDev } from './manifest.js'; -import { resolveServerBuildModule } from './server-utils.js'; +import { + normalizeEffectError, + runPluginEffect, + tryPluginPromise, +} from './effect-runtime.js'; +import { resolveServerBuildModuleEffect } from './server-utils.js'; export type ReactRouterDevManifest = ReactRouterManifestForDev; @@ -118,29 +124,50 @@ export const getEnvironmentStats = ( }); }; -const evaluateServerBuild = async ( +const startServerBuildEvaluationEffect = ( server: RsbuildDevServer, entryName: string -): Promise => { - const loaded = await server.environments.node.loadBundle(entryName); - return resolveServerBuildModule( - loaded, - `Server entry ${JSON.stringify(entryName)}` +): Effect.Effect => { + let loaded: PromiseLike | unknown; + try { + loaded = server.environments.node.loadBundle(entryName); + } catch (cause) { + return Effect.fail(normalizeEffectError(cause)); + } + + return tryPluginPromise(() => loaded).pipe( + Effect.flatMap(buildModule => + resolveServerBuildModuleEffect( + buildModule, + `Server entry ${JSON.stringify(entryName)}` + ) + ) ); }; -export const evaluateServerBuilds = async ( +export const evaluateServerBuildsEffect = ( server: RsbuildDevServer, entryNames: readonly string[] -): Promise => { - const evaluated = await Promise.all( - entryNames.map(async entryName => [ - entryName, - await evaluateServerBuild(server, entryName), - ]) +): Effect.Effect => + Effect.forEach( + entryNames.map(entryName => + startServerBuildEvaluationEffect(server, entryName).pipe( + Effect.map(build => [entryName, build] as const) + ) + ), + evaluation => evaluation, + { concurrency: 'unbounded' } + ).pipe( + Effect.map( + evaluated => Object.fromEntries(evaluated) as Record + ) ); - return Object.fromEntries(evaluated) as Record; -}; + +export const evaluateServerBuilds = ( + server: RsbuildDevServer, + entryNames: readonly string[] +): Promise => + runPluginEffect(evaluateServerBuildsEffect(server, entryNames)); const assertBuildMatchesManifest = ( entryName: string, diff --git a/src/dev-runtime-controller.ts b/src/dev-runtime-controller.ts index 4746d71..df67daa 100644 --- a/src/dev-runtime-controller.ts +++ b/src/dev-runtime-controller.ts @@ -1,4 +1,5 @@ import type { RsbuildConfig, RsbuildPluginAPI, Rspack } from '@rsbuild/core'; +import { Effect } from 'effect'; import type { ServerBuild } from 'react-router'; import { PLUGIN_NAME } from './constants.js'; import { @@ -23,6 +24,12 @@ import { createDevRuntimeSessionManager, type RuntimeBinding, } from './dev-runtime-session.js'; +import { + normalizeEffectError, + runPluginEffect, + tryPluginPromise, + tryPluginSync, +} from './effect-runtime.js'; type ServerSetup = Exclude< NonNullable['setup']>, @@ -86,25 +93,38 @@ export const createReactRouterDevRuntimeController = ({ const compilationIdentities = createCompilationIdentityTracker(); const { getCompilationIdentity } = compilationIdentities; - const finishRuntimeAttempt = async ( + const finishRuntimeAttemptEffect = ( binding: RuntimeBinding, pair: DevCompilerPair, stats: Rspack.Stats | Rspack.MultiStats, changes: Parameters[1], identity: Parameters[2] - ): Promise => { - const result = await binding.runtime.finishAttempt( - stats, - changes, - identity + ): Effect.Effect => + tryPluginPromise(() => + binding.runtime.finishAttempt(stats, changes, identity) + ).pipe( + Effect.flatMap(result => + tryPluginSync(() => { + if ( + result === 'retry-node' && + sessions.getActiveBinding()?.id === binding.id + ) { + pair.node.watching?.invalidate(); + } + }) + ) + ); + + const finishRuntimeAttempt = ( + binding: RuntimeBinding, + pair: DevCompilerPair, + stats: Rspack.Stats | Rspack.MultiStats, + changes: Parameters[1], + identity: Parameters[2] + ): Promise => + runPluginEffect( + finishRuntimeAttemptEffect(binding, pair, stats, changes, identity) ); - if ( - result === 'retry-node' && - sessions.getActiveBinding()?.id === binding.id - ) { - pair.node.watching?.invalidate(); - } - }; const flushSettledAttempt = ( binding: RuntimeBinding, @@ -126,23 +146,23 @@ export const createReactRouterDevRuntimeController = ({ ) { return; } - void binding.runtime - .finishAttempt(pending.stats, pending.changes, pending.identity) - .then(result => { - if ( - result === 'retry-node' && - sessions.getActiveBinding()?.id === binding.id - ) { - pair.node.watching?.invalidate(); - } - }) - .catch(cause => { - if (sessions.getActiveBinding()?.id === binding.id) { - binding.runtime.failAttempt( - cause instanceof Error ? cause : new Error(String(cause)) - ); - } - }); + void runPluginEffect( + finishRuntimeAttemptEffect( + binding, + pair, + pending.stats, + pending.changes, + pending.identity + ).pipe( + Effect.catchAll(cause => + tryPluginSync(() => { + if (sessions.getActiveBinding()?.id === binding.id) { + binding.runtime.failAttempt(normalizeEffectError(cause)); + } + }) + ) + ) + ); }; const rejectUnsupportedCompiler = (reason: string): void => { diff --git a/src/dev-runtime-session.ts b/src/dev-runtime-session.ts index 047a3c9..d07305f 100644 --- a/src/dev-runtime-session.ts +++ b/src/dev-runtime-session.ts @@ -1,7 +1,13 @@ import type { RsbuildDevServer } from '@rsbuild/core'; +import { Effect } from 'effect'; import { PLUGIN_NAME } from './constants.js'; import type { ReactRouterDevRuntime } from './dev-generation.js'; import type { DevCompilerPair } from './dev-runtime-compilation.js'; +import { + runPluginEffect, + tryPluginPromise, + tryPluginSync, +} from './effect-runtime.js'; export type RuntimeBinding = { id: number; @@ -109,18 +115,19 @@ export const createDevRuntimeSessionManager = ( if (observation.promise) { return observation.promise; } - let closePromise: Promise; - try { - closePromise = close(); - } catch (cause) { - closePromise = Promise.reject(cause); - } - observation.promise = closePromise; - void closePromise.then( - () => applyCloseOutcome(observation, { ok: true }), - cause => applyCloseOutcome(observation, { ok: false, cause }) + observation.promise = runPluginEffect( + tryPluginPromise(close).pipe( + Effect.tap(() => + tryPluginSync(() => applyCloseOutcome(observation, { ok: true })) + ), + Effect.catchAll(cause => + tryPluginSync(() => + applyCloseOutcome(observation, { ok: false, cause }) + ).pipe(Effect.zipRight(Effect.fail(cause))) + ) + ) ); - return closePromise; + return observation.promise; }; closeObservationByServer.set(server, observation); return observation; diff --git a/src/effect-runtime.ts b/src/effect-runtime.ts new file mode 100644 index 0000000..064ffc0 --- /dev/null +++ b/src/effect-runtime.ts @@ -0,0 +1,106 @@ +import { Cause, Duration, Effect, Exit, Fiber, Option } from 'effect'; + +export const DEV_BACKGROUND_STARTUP_DELAY_MS = 3_000; + +export const normalizeEffectError = (cause: unknown): Error => + cause instanceof Error ? cause : new Error(String(cause)); + +const normalizeEffectCause = (cause: Cause.Cause): Error => { + const failure = Cause.failureOption(cause); + if (Option.isSome(failure)) { + return normalizeEffectError(failure.value); + } + return normalizeEffectError(Cause.squash(cause)); +}; + +export const runPluginEffect = async ( + effect: Effect.Effect +): Promise => { + const exit = await Effect.runPromiseExit(effect); + if (Exit.isSuccess(exit)) { + return exit.value; + } + throw normalizeEffectCause(exit.cause); +}; + +export const tryPluginSync = ( + evaluate: () => A +): Effect.Effect => + Effect.try({ + try: evaluate, + catch: normalizeEffectError, + }); + +export const tryPluginPromise = ( + evaluate: () => PromiseLike | A +): Effect.Effect => + Effect.tryPromise({ + try: () => Promise.resolve(evaluate()), + catch: normalizeEffectError, + }); + +type DelayedPluginTask = { + schedule(): void; + cancelEffect(): Effect.Effect; + cancel(): Promise; +}; + +export const createDelayedPluginTask = ({ + delayMs, + run, + onError, +}: { + delayMs: number; + run: () => Effect.Effect; + onError: (error: Error) => void; +}): DelayedPluginTask => { + let activeFiber: ReturnType | undefined; + let activeToken: symbol | undefined; + + const cancelEffect = (): Effect.Effect => + Effect.sync(() => { + const fiber = activeFiber; + activeToken = undefined; + activeFiber = undefined; + return fiber; + }).pipe( + Effect.flatMap(fiber => + fiber ? Fiber.interrupt(fiber).pipe(Effect.asVoid) : Effect.void + ) + ); + + return { + schedule(): void { + if (activeFiber) { + return; + } + + const token = Symbol(); + activeToken = token; + activeFiber = Effect.runFork( + Effect.sleep(Duration.millis(delayMs)).pipe( + Effect.zipRight(Effect.suspend(run)), + Effect.catchAll(error => + Effect.sync(() => { + onError(error); + }) + ), + Effect.ensuring( + Effect.sync(() => { + if (activeToken === token) { + activeToken = undefined; + activeFiber = undefined; + } + }) + ) + ) + ); + }, + + cancelEffect, + + async cancel(): Promise { + await runPluginEffect(cancelEffect()); + }, + }; +}; diff --git a/src/index.ts b/src/index.ts index 31399ae..b854772 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { type RsbuildPlugin, type Rspack, } from '@rsbuild/core'; +import { Effect } from 'effect'; import { createJiti } from 'jiti'; import { relative, resolve } from 'pathe'; @@ -63,7 +64,7 @@ import { } from './route-watch.js'; import { validateRouteConfig } from './route-config.js'; import { - getBuildManifest, + getBuildManifestEffect, getRoutesByServerBundleId, } from './build-manifest.js'; import { @@ -79,6 +80,12 @@ import { } from './performance.js'; import { mapVirtualModules } from './virtual-modules.js'; import { createReactRouterDevRuntimeController } from './dev-runtime-controller.js'; +import { + createDelayedPluginTask, + DEV_BACKGROUND_STARTUP_DELAY_MS, + runPluginEffect, + tryPluginPromise, +} from './effect-runtime.js'; import { registerReactRouterTypegen } from './typegen.js'; export { loadReactRouterServerBuild } from './dev-generation.js'; @@ -464,36 +471,87 @@ export const pluginReactRouter = ( ...routeTopologyWatchFiles, ]; let closeRouteTopologyWatcher: (() => Promise) | undefined; + let routeTopologyWatcherClosed = false; + + const reportRouteTopologyWatcherError = (error: unknown): void => { + api.logger.warn( + `[${PLUGIN_NAME}] Failed to watch route topology changes: ${error}` + ); + }; - api.onBeforeStartDevServer(async () => { - await ensureDevRestartMarker(routeRestartMarkerPath); - closeRouteTopologyWatcher = await createRouteTopologyWatcher({ - watchDirectory, - getRouteTopology: getWatchedRouteTopology, - initialRouteTopology: createRouteTopologySnapshot( - rootRouteFile, - routeConfig - ), - restartMarkerPath: routeRestartMarkerPath, - onRouteTopologyChange: pluginOptions.onRouteTopologyChange, - onError: error => { - api.logger.warn( - `[${PLUGIN_NAME}] Failed to watch route topology changes: ${error}` + const routeTopologyWatcherTask = createDelayedPluginTask({ + delayMs: DEV_BACKGROUND_STARTUP_DELAY_MS, + run: () => + Effect.gen(function* () { + yield* tryPluginPromise(() => + ensureDevRestartMarker(routeRestartMarkerPath) ); - }, - }); + const closeWatcher = yield* tryPluginPromise(() => + createRouteTopologyWatcher({ + watchDirectory, + getRouteTopology: getWatchedRouteTopology, + initialRouteTopology: createRouteTopologySnapshot( + rootRouteFile, + routeConfig + ), + restartMarkerPath: routeRestartMarkerPath, + onRouteTopologyChange: pluginOptions.onRouteTopologyChange, + onError: reportRouteTopologyWatcherError, + }) + ); + if (routeTopologyWatcherClosed) { + yield* tryPluginPromise(() => closeWatcher()); + return; + } + closeRouteTopologyWatcher = closeWatcher; + }), + onError: reportRouteTopologyWatcherError, }); - api.onCloseDevServer(async () => { - await closeRouteTopologyWatcher?.(); - closeRouteTopologyWatcher = undefined; - }); - api.onCloseBuild(async () => { - await routeTransformExecutor.close(); - }); - api.onCloseDevServer(async () => { - await routeTransformExecutor.close(); - }); + const scheduleRouteTopologyWatcher = (): void => { + if (routeTopologyWatcherClosed || closeRouteTopologyWatcher) { + return; + } + routeTopologyWatcherTask.schedule(); + }; + + if (!isBuild) { + api.onBeforeStartDevServer(() => { + routeTopologyWatcherClosed = false; + }); + + api.onAfterDevCompile(() => { + scheduleRouteTopologyWatcher(); + }); + } + + const closeRouteTopologyWatcherEffect = (): Effect.Effect< + void, + Error, + never + > => + Effect.gen(function* () { + routeTopologyWatcherClosed = true; + yield* routeTopologyWatcherTask.cancelEffect(); + yield* tryPluginPromise(() => closeRouteTopologyWatcher?.()); + closeRouteTopologyWatcher = undefined; + }); + const closeRouteTransformExecutorEffect = (): Effect.Effect< + void, + Error, + never + > => tryPluginPromise(() => routeTransformExecutor.close()); + + api.onCloseDevServer(() => + runPluginEffect( + closeRouteTopologyWatcherEffect().pipe( + Effect.zipRight(closeRouteTransformExecutorEffect()) + ) + ) + ); + api.onCloseBuild(() => + runPluginEffect(closeRouteTransformExecutorEffect()) + ); type ReactRouterManifest = Awaited< ReturnType @@ -598,11 +656,13 @@ export const pluginReactRouter = ( }, {} as Record ); - const buildManifest = await getBuildManifest({ - reactRouterConfig: resolvedConfigWithRoutes, - routes, - rootDirectory: process.cwd(), - }); + const buildManifest = await runPluginEffect( + getBuildManifestEffect({ + reactRouterConfig: resolvedConfigWithRoutes, + routes, + rootDirectory: process.cwd(), + }) + ); const routesByServerBundleId = getRoutesByServerBundleId( buildManifest, routes @@ -653,31 +713,35 @@ export const pluginReactRouter = ( } ); - api.onAfterBuild(async ({ environments }) => { - await runReactRouterPrerenderBuild({ - api, - hasWebEnvironment: Boolean(environments.web), - buildDirectory, - serverBuildFile, - ssr, - isPrerenderEnabled, - prerenderConfig, - prerenderPaths, - basename, - future, - routes, - latestBrowserManifest, - latestBrowserManifestModuleExports, - clientStats, - pluginOptions, - appDirectory, - assetPrefix, - routeChunkOptions, - buildManifest, - resolvedConfigWithRoutes, - buildEnd, - }); - }); + api.onAfterBuild(({ environments }) => + runPluginEffect( + tryPluginPromise(() => + runReactRouterPrerenderBuild({ + api, + hasWebEnvironment: Boolean(environments.web), + buildDirectory, + serverBuildFile, + ssr, + isPrerenderEnabled, + prerenderConfig, + prerenderPaths, + basename, + future, + routes, + latestBrowserManifest, + latestBrowserManifestModuleExports, + clientStats, + pluginOptions, + appDirectory, + assetPrefix, + routeChunkOptions, + buildManifest, + resolvedConfigWithRoutes, + buildEnd, + }) + ) + ) + ); const allowedActionOriginsForBuild = allowedActionOrigins === false ? undefined : allowedActionOrigins; diff --git a/src/manifest.ts b/src/manifest.ts index 3a0cb42..955bd28 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; import { dirname, isAbsolute, relative, resolve } from 'pathe'; +import { Effect } from 'effect'; import type { Route, PluginOptions, RouteManifestItem } from './types.js'; import type { RouteConfigEntry } from '@react-router/dev/routes'; import { combineURLs, createRouteId } from './plugin-utils.js'; @@ -15,7 +16,8 @@ import { type RouteChunkConfig, } from './route-chunks.js'; import { getRouteModuleAnalysis } from './export-utils.js'; -import { getDefaultConcurrency, mapWithConcurrency } from './concurrency.js'; +import { getDefaultConcurrency } from './concurrency.js'; +import { runPluginEffect, tryPluginPromise } from './effect-runtime.js'; const ROUTE_ANALYSIS_CONCURRENCY = Math.max( 1, @@ -273,204 +275,248 @@ export const getReactRouterManifestChunkNames = ( return chunkNames; }; -export async function generateReactRouterManifestForDev( +export function generateReactRouterManifestForDevEffect( routes: Record, _options: PluginOptions, clientStats: ReactRouterManifestStats | undefined, context: string, assetPrefix = '/', routeChunkOptions?: RouteChunkManifestOptions -): Promise { - const result: Record = {}; - const splitRouteModules = routeChunkOptions?.splitRouteModules ?? false; - const enforceSplitRouteModules = splitRouteModules === 'enforce'; - const isBuild = routeChunkOptions?.isBuild ?? false; - const routeChunkConfig: RouteChunkConfig | null = - splitRouteModules && routeChunkOptions?.rootRouteFile - ? { - splitRouteModules, - appDirectory: context, - rootRouteFile: routeChunkOptions.rootRouteFile, - } - : null; - - const getAssetsForChunk = (chunkName: string): string[] => { - const assets = clientStats?.assetsByChunkName?.[chunkName]; - if (!assets) { - return [`${DEFAULT_MANIFEST_DIR}/${chunkName}.js`]; - } - const entrypointCssAssets = - clientStats?.entrypointFilesByName?.[chunkName]?.filter(asset => - asset.endsWith('.css') - ) ?? []; - const cssAssets = [ - ...assets.filter(asset => asset.endsWith('.css')), - ...entrypointCssAssets, - ].filter((asset, index, all) => all.indexOf(asset) === index); - const nonCssAssets = assets.filter(asset => !asset.endsWith('.css')); - - if (!nonCssAssets.some(asset => asset.endsWith('.js'))) { - return [ - `${DEFAULT_MANIFEST_DIR}/${chunkName}.js`, - ...nonCssAssets, - ...cssAssets, - ]; - } - return [...nonCssAssets, ...cssAssets]; - }; - - const getModulePathForChunk = (chunkName: string): string | undefined => { - const assets = getAssetsForChunk(chunkName); - const jsAssets = assets.filter(asset => asset.endsWith('.js')); - return jsAssets[0] ? combineURLs(assetPrefix, jsAssets[0]) : undefined; - }; +): Effect.Effect { + return Effect.gen(function* () { + const result: Record = {}; + const splitRouteModules = routeChunkOptions?.splitRouteModules ?? false; + const enforceSplitRouteModules = splitRouteModules === 'enforce'; + const isBuild = routeChunkOptions?.isBuild ?? false; + const routeChunkConfig: RouteChunkConfig | null = + splitRouteModules && routeChunkOptions?.rootRouteFile + ? { + splitRouteModules, + appDirectory: context, + rootRouteFile: routeChunkOptions.rootRouteFile, + } + : null; + + const getAssetsForChunk = (chunkName: string): string[] => { + const assets = clientStats?.assetsByChunkName?.[chunkName]; + if (!assets) { + return [`${DEFAULT_MANIFEST_DIR}/${chunkName}.js`]; + } + const entrypointCssAssets = + clientStats?.entrypointFilesByName?.[chunkName]?.filter(asset => + asset.endsWith('.css') + ) ?? []; + const cssAssets = [ + ...assets.filter(asset => asset.endsWith('.css')), + ...entrypointCssAssets, + ].filter((asset, index, all) => all.indexOf(asset) === index); + const nonCssAssets = assets.filter(asset => !asset.endsWith('.css')); + + if (!nonCssAssets.some(asset => asset.endsWith('.js'))) { + return [ + `${DEFAULT_MANIFEST_DIR}/${chunkName}.js`, + ...nonCssAssets, + ...cssAssets, + ]; + } + return [...nonCssAssets, ...cssAssets]; + }; - const manifestEntries = await mapWithConcurrency( - Object.entries(routes), - ROUTE_ANALYSIS_CONCURRENCY, - async ([key, route]) => { - const routeEntryName = getRouteEntryName(route); - const assets = getAssetsForChunk(routeEntryName); + const getModulePathForChunk = (chunkName: string): string | undefined => { + const assets = getAssetsForChunk(chunkName); const jsAssets = assets.filter(asset => asset.endsWith('.js')); - let cssAssets = assets.filter(asset => asset.endsWith('.css')); - const routeFilePath = resolve(context, route.file); - let exports = new Set(); - let routeModuleExports: readonly string[] = []; - let hasRouteChunkByExportName: ReturnType< - typeof createEmptyRouteChunkByExportName - > | null = null; - - try { - const { code, exports: exportNames } = - await getRouteModuleAnalysis(routeFilePath); - if ( - !isBuild && - cssAssets.length === 0 && - /\.(?:css|less|sass|scss)(?:\?[^'"`]+)?['"`]/.test(code) - ) { - cssAssets = [ - `${DEFAULT_MANIFEST_DIR.replace('/js', '/css')}/${routeEntryName}.css`, - ]; - } - routeModuleExports = exportNames; - exports = new Set(exportNames); - - if (isBuild && routeChunkConfig) { - const { hasRouteChunkByExportName: chunkInfo } = - await detectRouteChunksIfEnabled( - routeChunkOptions?.cache, - routeChunkConfig, - routeFilePath, - code - ); - hasRouteChunkByExportName = chunkInfo; - } - } catch (error) { - if (isBuild) { - throw error; - } - console.error(`Failed to analyze route file ${routeFilePath}:`, error); - } + return jsAssets[0] ? combineURLs(assetPrefix, jsAssets[0]) : undefined; + }; - const hasClientAction = exports.has(CLIENT_EXPORTS.clientAction); - const hasClientLoader = exports.has(CLIENT_EXPORTS.clientLoader); - const hasClientMiddleware = exports.has(CLIENT_EXPORTS.clientMiddleware); - const hasDefaultExport = exports.has('default'); - const routeChunkMap = hasRouteChunkByExportName; - - if (isBuild && enforceSplitRouteModules && routeChunkConfig) { - validateRouteChunks({ - config: routeChunkConfig, - id: routeFilePath, - valid: buildManifestChunkValidity( - exports, - routeChunkMap ?? createEmptyRouteChunkByExportName() - ), - }); + const manifestEntries = yield* Effect.forEach( + Object.entries(routes), + ([key, route]) => + Effect.gen(function* () { + const routeEntryName = getRouteEntryName(route); + const assets = getAssetsForChunk(routeEntryName); + const jsAssets = assets.filter(asset => asset.endsWith('.js')); + let cssAssets = assets.filter(asset => asset.endsWith('.css')); + const routeFilePath = resolve(context, route.file); + let exports = new Set(); + let routeModuleExports: readonly string[] = []; + let hasRouteChunkByExportName: ReturnType< + typeof createEmptyRouteChunkByExportName + > | null = null; + + const routeAnalysis = yield* Effect.gen(function* () { + const { code, exports: exportNames } = yield* tryPluginPromise(() => + getRouteModuleAnalysis(routeFilePath) + ); + const fallbackCssAssets = + !isBuild && + cssAssets.length === 0 && + /\.(?:css|less|sass|scss)(?:\?[^'"`]+)?['"`]/.test(code) + ? [ + `${DEFAULT_MANIFEST_DIR.replace('/js', '/css')}/${routeEntryName}.css`, + ] + : cssAssets; + const chunkInfo = + isBuild && routeChunkConfig + ? yield* tryPluginPromise(() => + detectRouteChunksIfEnabled( + routeChunkOptions?.cache, + routeChunkConfig, + routeFilePath, + code + ) + ) + : null; + + return { + cssAssets: fallbackCssAssets, + exports: new Set(exportNames), + routeModuleExports: exportNames, + hasRouteChunkByExportName: + chunkInfo?.hasRouteChunkByExportName ?? null, + }; + }).pipe( + Effect.catchAll(error => { + if (isBuild) { + return Effect.fail(error); + } + return Effect.sync(() => { + console.error( + `Failed to analyze route file ${routeFilePath}:`, + error + ); + return { + cssAssets, + exports, + routeModuleExports, + hasRouteChunkByExportName, + }; + }); + }) + ); + + cssAssets = routeAnalysis.cssAssets; + exports = routeAnalysis.exports; + routeModuleExports = routeAnalysis.routeModuleExports; + hasRouteChunkByExportName = routeAnalysis.hasRouteChunkByExportName; + + const hasClientAction = exports.has(CLIENT_EXPORTS.clientAction); + const hasClientLoader = exports.has(CLIENT_EXPORTS.clientLoader); + const hasClientMiddleware = exports.has( + CLIENT_EXPORTS.clientMiddleware + ); + const hasDefaultExport = exports.has('default'); + const routeChunkMap = hasRouteChunkByExportName; + + if (isBuild && enforceSplitRouteModules && routeChunkConfig) { + validateRouteChunks({ + config: routeChunkConfig, + id: routeFilePath, + valid: buildManifestChunkValidity( + exports, + routeChunkMap ?? createEmptyRouteChunkByExportName() + ), + }); + } + + return [ + key, + { + id: route.id, + parentId: route.parentId, + path: route.path, + index: route.index, + caseSensitive: route.caseSensitive, + module: combineURLs(assetPrefix, jsAssets[0] || ''), + clientActionModule: routeChunkMap?.clientAction + ? getModulePathForChunk( + getRouteChunkEntryName(route.id, 'clientAction') + ) + : undefined, + clientLoaderModule: routeChunkMap?.clientLoader + ? getModulePathForChunk( + getRouteChunkEntryName(route.id, 'clientLoader') + ) + : undefined, + clientMiddlewareModule: routeChunkMap?.clientMiddleware + ? getModulePathForChunk( + getRouteChunkEntryName(route.id, 'clientMiddleware') + ) + : undefined, + hydrateFallbackModule: routeChunkMap?.HydrateFallback + ? getModulePathForChunk( + getRouteChunkEntryName(route.id, 'HydrateFallback') + ) + : undefined, + hasAction: exports.has(SERVER_EXPORTS.action), + hasLoader: exports.has(SERVER_EXPORTS.loader), + hasClientAction, + hasClientLoader, + hasClientMiddleware, + hasDefaultExport, + hasErrorBoundary: exports.has(CLIENT_EXPORTS.ErrorBoundary), + imports: jsAssets.map(asset => combineURLs(assetPrefix, asset)), + css: cssAssets.map(asset => combineURLs(assetPrefix, asset)), + }, + routeModuleExports, + ] as const; + }), + { + concurrency: Math.max( + 1, + Math.min(ROUTE_ANALYSIS_CONCURRENCY, Object.keys(routes).length) + ), } - - return [ - key, - { - id: route.id, - parentId: route.parentId, - path: route.path, - index: route.index, - caseSensitive: route.caseSensitive, - module: combineURLs(assetPrefix, jsAssets[0] || ''), - clientActionModule: routeChunkMap?.clientAction - ? getModulePathForChunk( - getRouteChunkEntryName(route.id, 'clientAction') - ) - : undefined, - clientLoaderModule: routeChunkMap?.clientLoader - ? getModulePathForChunk( - getRouteChunkEntryName(route.id, 'clientLoader') - ) - : undefined, - clientMiddlewareModule: routeChunkMap?.clientMiddleware - ? getModulePathForChunk( - getRouteChunkEntryName(route.id, 'clientMiddleware') - ) - : undefined, - hydrateFallbackModule: routeChunkMap?.HydrateFallback - ? getModulePathForChunk( - getRouteChunkEntryName(route.id, 'HydrateFallback') - ) - : undefined, - hasAction: exports.has(SERVER_EXPORTS.action), - hasLoader: exports.has(SERVER_EXPORTS.loader), - hasClientAction, - hasClientLoader, - hasClientMiddleware, - hasDefaultExport, - hasErrorBoundary: exports.has(CLIENT_EXPORTS.ErrorBoundary), - imports: jsAssets.map(asset => combineURLs(assetPrefix, asset)), - css: cssAssets.map(asset => combineURLs(assetPrefix, asset)), - }, - routeModuleExports, - ] as const; + ); + + const routeModuleExportsByRouteId: RouteManifestModuleExports = {}; + for (const [ + key, + routeManifestItem, + routeModuleExports, + ] of manifestEntries) { + result[key] = routeManifestItem; + routeModuleExportsByRouteId[key] = routeModuleExports; } - ); - const routeModuleExportsByRouteId: RouteManifestModuleExports = {}; - for (const [key, routeManifestItem, routeModuleExports] of manifestEntries) { - result[key] = routeManifestItem; - routeModuleExportsByRouteId[key] = routeModuleExports; - } + const entryAssets = getAssetsForChunk('entry.client'); + const entryJsAssets = entryAssets.filter(asset => asset.endsWith('.js')); + const entryCssAssets = entryAssets.filter(asset => asset.endsWith('.css')); + + const fingerprintedValues = { + entry: { + module: combineURLs(assetPrefix, entryJsAssets[0] || ''), + imports: entryJsAssets.map(asset => combineURLs(assetPrefix, asset)), + css: entryCssAssets.map(asset => combineURLs(assetPrefix, asset)), + }, + routes: result, + }; + const version = getManifestVersion(fingerprintedValues, isBuild); + const manifestPath = getReactRouterManifestPath({ + version, + isBuild, + entryModulePath: entryJsAssets[0], + }); + + const manifest = { + version, + url: combineURLs(assetPrefix, manifestPath), + hmr: undefined, + entry: fingerprintedValues.entry, + sri: undefined, + routes: result, + }; - const entryAssets = getAssetsForChunk('entry.client'); - const entryJsAssets = entryAssets.filter(asset => asset.endsWith('.js')); - const entryCssAssets = entryAssets.filter(asset => asset.endsWith('.css')); - - const fingerprintedValues = { - entry: { - module: combineURLs(assetPrefix, entryJsAssets[0] || ''), - imports: entryJsAssets.map(asset => combineURLs(assetPrefix, asset)), - css: entryCssAssets.map(asset => combineURLs(assetPrefix, asset)), - }, - routes: result, - }; - const version = getManifestVersion(fingerprintedValues, isBuild); - const manifestPath = getReactRouterManifestPath({ - version, - isBuild, - entryModulePath: entryJsAssets[0], + return { + manifest, + moduleExportsByRouteId: routeModuleExportsByRouteId, + }; }); +} - const manifest = { - version, - url: combineURLs(assetPrefix, manifestPath), - hmr: undefined, - entry: fingerprintedValues.entry, - sri: undefined, - routes: result, - }; - - return { - manifest, - moduleExportsByRouteId: routeModuleExportsByRouteId, - }; +export async function generateReactRouterManifestForDev( + ...args: Parameters +): Promise { + return runPluginEffect(generateReactRouterManifestForDevEffect(...args)); } export async function getReactRouterManifestForDev( diff --git a/src/parallel-route-transforms.ts b/src/parallel-route-transforms.ts index 3dd61ff..c2dbb7c 100644 --- a/src/parallel-route-transforms.ts +++ b/src/parallel-route-transforms.ts @@ -1,9 +1,8 @@ import { Worker } from 'node:worker_threads'; +import { Effect } from 'effect'; import { setBoundedCacheEntry } from './bounded-cache.js'; -import { - getAvailableCpuCount, - getDefaultConcurrency, -} from './concurrency.js'; +import { getAvailableCpuCount, getDefaultConcurrency } from './concurrency.js'; +import { runPluginEffect, tryPluginPromise } from './effect-runtime.js'; import { executeRouteTransformTask, type RouteTransformResult, @@ -157,15 +156,25 @@ class ParallelRouteTransformExecutor implements RouteTransformExecutor { this.#closed = true; const workers = this.#workers ?? []; this.#workers = []; - this.#closePromise = Promise.all( - workers.map(async state => { - for (const pending of state.pending.values()) { - pending.reject(new Error('Route transform worker closed.')); - } - state.pending.clear(); - await state.worker.terminate(); - }) - ).then(() => undefined); + this.#closePromise = runPluginEffect( + Effect.forEach( + workers, + state => + Effect.sync(() => { + for (const pending of state.pending.values()) { + pending.reject(new Error('Route transform worker closed.')); + } + state.pending.clear(); + }).pipe( + Effect.zipRight( + tryPluginPromise(() => state.worker.terminate()).pipe( + Effect.asVoid + ) + ) + ), + { concurrency: 'unbounded', discard: true } + ) + ); return this.#closePromise; } diff --git a/src/performance.ts b/src/performance.ts index f15c8e2..8a16af2 100644 --- a/src/performance.ts +++ b/src/performance.ts @@ -1,3 +1,5 @@ +import { Effect } from 'effect'; + type OperationTiming = { count: number; // Total sums every recorded duration, so parallel work can make it larger @@ -70,6 +72,12 @@ export type ReactRouterPerformanceProfiler = { resource: string, callback: () => T ): T; + recordEffect( + environment: string | undefined, + operation: string, + resource: string, + effect: Effect.Effect + ): Effect.Effect; flush( environment: string | undefined, details?: Pick @@ -225,6 +233,30 @@ export const createReactRouterPerformanceProfiler = ({ recordDuration(resolvedEnvironment, operation, resource, start, end); } }, + recordEffect(environment, operation, resource, effect) { + if (!enabled) { + return effect; + } + + const resolvedEnvironment = environment ?? 'unknown'; + return Effect.suspend(() => { + const start = performance.now(); + return effect.pipe( + Effect.ensuring( + Effect.sync(() => { + const end = performance.now(); + recordDuration( + resolvedEnvironment, + operation, + resource, + start, + end + ); + }) + ) + ); + }); + }, flush(environment, details = {}) { if (!enabled) { return; diff --git a/src/prerender-build.ts b/src/prerender-build.ts index 098782b..e66a397 100644 --- a/src/prerender-build.ts +++ b/src/prerender-build.ts @@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; import { pathToFileURL } from 'node:url'; import fsExtra from 'fs-extra'; +import { Effect } from 'effect'; import type { RsbuildPluginAPI } from '@rsbuild/core'; import { createRequestHandler, @@ -22,7 +23,6 @@ import { getPrerenderConcurrency, getSsrFalsePrerenderExportErrors, normalizePrerenderMatchPath, - withBuildRequest, } from './prerender.js'; import type { Config, @@ -30,6 +30,7 @@ import type { } from './react-router-config.js'; import { resolveServerBuildModule } from './server-utils.js'; import type { PluginOptions, Route } from './types.js'; +import { runPluginEffect, tryPluginPromise } from './effect-runtime.js'; type ReactRouterManifest = Awaited< ReturnType @@ -131,6 +132,34 @@ const createDataRequestPath = ( : `${prerenderPath.replace(/\/$/, '')}.data`; }; +export const createBuildRequestEffect = ( + input: string | URL, + init: RequestInit | undefined, + handle: (request: Request) => Promise +): Effect.Effect => + Effect.acquireUseRelease( + Effect.sync(() => new AbortController()), + controller => + tryPluginPromise(() => + handle( + new Request(input, { + ...init, + signal: controller.signal, + }) + ) + ), + controller => + Effect.sync(() => { + controller.abort(); + }) + ); + +export const withBuildRequest = ( + input: string | URL, + init: RequestInit | undefined, + handle: (request: Request) => Promise +): Promise => runPluginEffect(createBuildRequestEffect(input, init, handle)); + const prerenderData = async ({ handler, prerenderPath, @@ -401,23 +430,56 @@ const validatePrerenderPathMatches = ( } }; -const runPrerenderPaths = async ({ +export const runBoundedPrerenderTasks = ( + prerenderPaths: string[], + concurrency: number, + renderPath: (path: string) => Promise +): Promise => + runPluginEffect( + createBoundedPrerenderTasksEffect(prerenderPaths, concurrency, path => + tryPluginPromise(() => renderPath(path)) + ) + ); + +export const createBoundedPrerenderTasksEffect = ( + prerenderPaths: string[], + concurrency: number, + renderPath: (path: string) => Effect.Effect +): Effect.Effect => + Effect.forEach(prerenderPaths, renderPath, { concurrency, discard: true }); + +const createPrerenderDataEffect = ( + options: Parameters[0] +): Effect.Effect => + tryPluginPromise(() => prerenderData(options)); + +const createPrerenderRouteEffect = ( + options: Parameters[0] +): Effect.Effect => + tryPluginPromise(() => prerenderRoute(options)); + +const createPrerenderResourceRouteEffect = ( + options: Parameters[0] +): Effect.Effect => + tryPluginPromise(() => prerenderResourceRoute(options)); + +const createPrerenderPathEffect = ({ + path, build, + buildRoutes, requestHandler, clientBuildDir, options, }: { + path: string; build: PrerenderServerBuild; + buildRoutes: ReturnType; requestHandler: (request: Request) => Promise; clientBuildDir: string; options: RunReactRouterPrerenderBuildOptions; -}): Promise => { - const { api, basename, future, prerenderConfig, prerenderPaths } = options; - const buildRoutes = createPrerenderRoutes(build.routes); - const concurrency = getPrerenderConcurrency(prerenderConfig); - const pending = new Set>(); - - const enqueue = async (path: string) => { +}): Effect.Effect => + Effect.gen(function* () { + const { api, basename, future } = options; const matches = matchRoutes(buildRoutes, normalizePrerenderMatchPath(path)); if (!matches) { return; @@ -431,7 +493,7 @@ const runPrerenderPaths = async ({ if (isResourceRoute) { if (manifestRoute.loader && routeId) { - await prerenderData({ + yield* createPrerenderDataEffect({ handler: requestHandler, prerenderPath: path, onlyRoutes: [routeId], @@ -441,7 +503,7 @@ const runPrerenderPaths = async ({ future.unstable_trailingSlashAwareDataRequests, api, }); - await prerenderResourceRoute({ + yield* createPrerenderResourceRouteEffect({ handler: requestHandler, prerenderPath: path, clientBuildDir, @@ -449,9 +511,11 @@ const runPrerenderPaths = async ({ api, }); } else { - api.logger.warn( - `⚠️ Skipping prerendering for resource route without a loader: ${routeId}` - ); + yield* Effect.sync(() => { + api.logger.warn( + `⚠️ Skipping prerendering for resource route without a loader: ${routeId}` + ); + }); } return; } @@ -463,20 +527,20 @@ const runPrerenderPaths = async ({ } return build.assets?.routes?.[matchedRouteId]?.hasLoader; }); - let data: string | undefined; - if (hasLoaders) { - data = await prerenderData({ - handler: requestHandler, - prerenderPath: path, - onlyRoutes: null, - clientBuildDir, - basename, - trailingSlashAwareDataRequests: - future.unstable_trailingSlashAwareDataRequests, - api, - }); - } - await prerenderRoute({ + const data = hasLoaders + ? yield* createPrerenderDataEffect({ + handler: requestHandler, + prerenderPath: path, + onlyRoutes: null, + clientBuildDir, + basename, + trailingSlashAwareDataRequests: + future.unstable_trailingSlashAwareDataRequests, + api, + }) + : undefined; + + yield* createPrerenderRouteEffect({ handler: requestHandler, prerenderPath: path, clientBuildDir, @@ -490,17 +554,35 @@ const runPrerenderPaths = async ({ } : undefined, }); - }; + }); - for (const path of prerenderPaths) { - const task = enqueue(path); - pending.add(task); - task.finally(() => pending.delete(task)); - if (pending.size >= concurrency) { - await Promise.race(pending); - } - } - await Promise.all(pending); +const runPrerenderPaths = async ({ + build, + requestHandler, + clientBuildDir, + options, +}: { + build: PrerenderServerBuild; + requestHandler: (request: Request) => Promise; + clientBuildDir: string; + options: RunReactRouterPrerenderBuildOptions; +}): Promise => { + const { prerenderConfig, prerenderPaths } = options; + const buildRoutes = createPrerenderRoutes(build.routes); + const concurrency = getPrerenderConcurrency(prerenderConfig); + + await runPluginEffect( + createBoundedPrerenderTasksEffect(prerenderPaths, concurrency, path => + createPrerenderPathEffect({ + path, + build, + buildRoutes, + requestHandler, + clientBuildDir, + options, + }) + ) + ); }; export const runReactRouterPrerenderBuild = async ( diff --git a/src/prerender.ts b/src/prerender.ts index d8613d6..a01fa8a 100644 --- a/src/prerender.ts +++ b/src/prerender.ts @@ -92,24 +92,6 @@ export const createPrerenderRoutes = ( export const normalizePrerenderMatchPath = (path: string): string => `/${path}/`.replace(/^\/\/+/, '/'); -export const withBuildRequest = async ( - input: string | URL, - init: RequestInit | undefined, - handle: (request: Request) => Promise -): Promise => { - const controller = new AbortController(); - try { - return await handle( - new Request(input, { - ...init, - signal: controller.signal, - }) - ); - } finally { - controller.abort(); - } -}; - export const getSsrFalsePrerenderExportErrors = ({ routes, manifestRoutes, diff --git a/src/react-router-config.ts b/src/react-router-config.ts index eb8990e..d7ac13d 100644 --- a/src/react-router-config.ts +++ b/src/react-router-config.ts @@ -4,6 +4,8 @@ import type { } from '@react-router/dev/config'; import type { NormalizedConfig } from '@rsbuild/core'; import type { RouteConfigEntry } from '@react-router/dev/routes'; +import { Effect } from 'effect'; +import { runPluginEffect, tryPluginPromise } from './effect-runtime.js'; export type BuildEndHook = { bivarianceHack(args: { @@ -45,6 +47,12 @@ type RouteManifestEntry = { type RouteManifest = Record; +type ResolveReactRouterConfigResult = { + resolved: ResolvedReactRouterConfig; + presets: NonNullable; + hasConfiguredServerModuleFormat: boolean; +}; + export type ResolvedReactRouterConfig = Readonly<{ appDirectory: string; basename: string; @@ -102,10 +110,15 @@ const mergeReactRouterConfig = (...configs: Config[]): Config => { buildEnd: async ( ...args: Parameters> ) => { - await Promise.all([ - configA.buildEnd?.(...args), - configB.buildEnd?.(...args), - ]); + await runPluginEffect( + Effect.all( + [ + tryPluginPromise(() => configA.buildEnd?.(...args)), + tryPluginPromise(() => configB.buildEnd?.(...args)), + ], + { discard: true } + ) + ); }, } : {}), @@ -127,75 +140,84 @@ const mergeReactRouterConfig = (...configs: Config[]): Config => { return configs.reduce(reducer, {}); }; -export const resolveReactRouterConfig = async ( +export const resolveReactRouterConfigEffect = ( reactRouterUserConfig: Config -): Promise<{ - resolved: ResolvedReactRouterConfig; - presets: NonNullable; - hasConfiguredServerModuleFormat: boolean; -}> => { - const presets = await Promise.all( - (reactRouterUserConfig.presets ?? []).map(async preset => { - if (!preset.name) { - throw new Error( - 'React Router presets must have a `name` property defined.' - ); - } - if (!preset.reactRouterConfig) { - return null; - } - const { buildEnd: _buildEnd, ...reactRouterUserConfigForPreset } = - reactRouterUserConfig; - const presetConfig = await preset.reactRouterConfig({ - reactRouterUserConfig: reactRouterUserConfigForPreset, - }); - if (!presetConfig) return null; - const { presets: _presets, ...rest } = presetConfig as Config; - return rest; - }) - ); - - const userAndPresetConfigs = mergeReactRouterConfig( - ...(presets.filter(Boolean) as Config[]), - reactRouterUserConfig - ); - - const resolvedFuture: FutureConfig = { - ...DEFAULT_CONFIG.future, - ...(userAndPresetConfigs.future ?? {}), - }; - const splitRouteModules = - userAndPresetConfigs.splitRouteModules ?? - userAndPresetConfigs.future?.v8_splitRouteModules ?? - DEFAULT_CONFIG.splitRouteModules; - const subResourceIntegrity = - userAndPresetConfigs.subResourceIntegrity ?? - userAndPresetConfigs.future?.unstable_subResourceIntegrity ?? - DEFAULT_CONFIG.subResourceIntegrity; - - let resolved: ResolvedReactRouterConfig = { - ...DEFAULT_CONFIG, - ...userAndPresetConfigs, - future: resolvedFuture, - splitRouteModules, - subResourceIntegrity, - allowedActionOrigins: - userAndPresetConfigs.allowedActionOrigins ?? - DEFAULT_CONFIG.allowedActionOrigins, - routes: DEFAULT_CONFIG.routes, - unstable_routeConfig: DEFAULT_CONFIG.unstable_routeConfig, - }; - if (!resolved.ssr) { - resolved = { - ...resolved, - serverBundles: undefined, +): Effect.Effect => + Effect.gen(function* () { + const presets = yield* Effect.forEach( + reactRouterUserConfig.presets ?? [], + preset => + Effect.gen(function* () { + if (!preset.name) { + return yield* Effect.fail( + new Error( + 'React Router presets must have a `name` property defined.' + ) + ); + } + if (!preset.reactRouterConfig) { + return null; + } + const { buildEnd: _buildEnd, ...reactRouterUserConfigForPreset } = + reactRouterUserConfig; + const presetConfig = yield* tryPluginPromise(() => + preset.reactRouterConfig?.({ + reactRouterUserConfig: reactRouterUserConfigForPreset, + }) + ); + if (!presetConfig) return null; + const { presets: _presets, ...rest } = presetConfig as Config; + return rest; + }), + { concurrency: 'unbounded' } + ); + + const userAndPresetConfigs = mergeReactRouterConfig( + ...(presets.filter(Boolean) as Config[]), + reactRouterUserConfig + ); + + const resolvedFuture: FutureConfig = { + ...DEFAULT_CONFIG.future, + ...(userAndPresetConfigs.future ?? {}), }; - } + const splitRouteModules = + userAndPresetConfigs.splitRouteModules ?? + userAndPresetConfigs.future?.v8_splitRouteModules ?? + DEFAULT_CONFIG.splitRouteModules; + const subResourceIntegrity = + userAndPresetConfigs.subResourceIntegrity ?? + userAndPresetConfigs.future?.unstable_subResourceIntegrity ?? + DEFAULT_CONFIG.subResourceIntegrity; - return { - resolved, - presets: reactRouterUserConfig.presets ?? [], - hasConfiguredServerModuleFormat: - userAndPresetConfigs.serverModuleFormat !== undefined, - }; -}; + let resolved: ResolvedReactRouterConfig = { + ...DEFAULT_CONFIG, + ...userAndPresetConfigs, + future: resolvedFuture, + splitRouteModules, + subResourceIntegrity, + allowedActionOrigins: + userAndPresetConfigs.allowedActionOrigins ?? + DEFAULT_CONFIG.allowedActionOrigins, + routes: DEFAULT_CONFIG.routes, + unstable_routeConfig: DEFAULT_CONFIG.unstable_routeConfig, + }; + if (!resolved.ssr) { + resolved = { + ...resolved, + serverBundles: undefined, + }; + } + + return { + resolved, + presets: reactRouterUserConfig.presets ?? [], + hasConfiguredServerModuleFormat: + userAndPresetConfigs.serverModuleFormat !== undefined, + }; + }); + +export const resolveReactRouterConfig = ( + reactRouterUserConfig: Config +): Promise => + runPluginEffect(resolveReactRouterConfigEffect(reactRouterUserConfig)); diff --git a/src/route-artifacts.ts b/src/route-artifacts.ts index 58dd4de..1cbdab9 100644 --- a/src/route-artifacts.ts +++ b/src/route-artifacts.ts @@ -2,6 +2,8 @@ import { CLIENT_ROUTE_EXPORTS_SET, SERVER_ONLY_ROUTE_EXPORTS_SET, } from './constants.js'; +import { Effect } from 'effect'; +import { runPluginEffect, tryPluginPromise } from './effect-runtime.js'; import { getExportNames } from './export-utils.js'; import { buildEnforceChunkValidity, @@ -70,86 +72,113 @@ export const buildRouteClientEntryCode = ({ return `export { ${reexports.join(', ')} } from ${JSON.stringify(target)};`; }; -export const createRouteClientEntryArtifact = async ({ +export const createRouteClientEntryArtifactEffect = ({ code, resourcePath, environmentName, isBuild, routeChunkCache, routeChunkConfig, -}: RouteClientEntryArtifactOptions): Promise => { - const isServer = environmentName === 'node'; - const mightHaveRouteChunks = - !isServer && - isBuild && - shouldAnalyzeRouteChunks(routeChunkConfig, resourcePath, code); - const routeChunkInfo = mightHaveRouteChunks - ? await detectRouteChunksIfEnabled( - routeChunkCache, - routeChunkConfig, +}: RouteClientEntryArtifactOptions): Effect.Effect< + RouteClientEntryArtifact, + Error, + never +> => + Effect.gen(function* () { + const isServer = environmentName === 'node'; + const mightHaveRouteChunks = + !isServer && + isBuild && + shouldAnalyzeRouteChunks(routeChunkConfig, resourcePath, code); + const routeChunkInfo = mightHaveRouteChunks + ? yield* tryPluginPromise(() => + detectRouteChunksIfEnabled( + routeChunkCache, + routeChunkConfig, + resourcePath, + code + ) + ) + : null; + const exportNames = + routeChunkInfo?.exportNames ?? + (yield* tryPluginPromise(() => getExportNames(code))); + const chunkedExports = routeChunkInfo?.chunkedExports ?? []; + return { + code: buildRouteClientEntryCode({ + exportNames, + chunkedExports, + isServer, resourcePath, - code - ) - : null; - const exportNames = - routeChunkInfo?.exportNames ?? (await getExportNames(code)); - const chunkedExports = routeChunkInfo?.chunkedExports ?? []; - return { - code: buildRouteClientEntryCode({ - exportNames, - chunkedExports, - isServer, - resourcePath, - }), - }; -}; + }), + }; + }); + +export const createRouteClientEntryArtifact = ( + options: RouteClientEntryArtifactOptions +): Promise => + runPluginEffect(createRouteClientEntryArtifactEffect(options)); -export const createRouteChunkArtifact = async ({ +export const createRouteChunkArtifactEffect = ({ code, resource, resourcePath, isBuild, routeChunkCache, routeChunkConfig, -}: RouteChunkArtifactOptions): Promise => { - const splitRouteModules = routeChunkConfig.splitRouteModules; - if (!isBuild || !splitRouteModules) { - return { - code: emptyRouteChunkSnippet(), - map: null, - }; - } +}: RouteChunkArtifactOptions): Effect.Effect< + RouteChunkArtifact, + Error, + never +> => + Effect.gen(function* () { + const splitRouteModules = routeChunkConfig.splitRouteModules; + if (!isBuild || !splitRouteModules) { + return { + code: emptyRouteChunkSnippet(), + map: null, + }; + } + + const chunkName = getRouteChunkNameFromModuleId(resource); + if (!chunkName) { + return yield* Effect.fail( + new Error(`Invalid route chunk name in "${resource}"`) + ); + } + if (chunkName !== 'main' && !code.includes(chunkName)) { + return { + code: emptyRouteChunkSnippet(), + map: null, + }; + } + + const chunk = yield* tryPluginPromise(() => + getRouteChunkIfEnabled( + routeChunkCache, + routeChunkConfig, + resourcePath, + chunkName, + code + ) + ); + + if (splitRouteModules === 'enforce' && chunkName === 'main' && chunk) { + const exportNames = yield* tryPluginPromise(() => getExportNames(chunk)); + validateRouteChunks({ + config: routeChunkConfig, + id: resourcePath, + valid: buildEnforceChunkValidity(exportNames), + }); + } - const chunkName = getRouteChunkNameFromModuleId(resource); - if (!chunkName) { - throw new Error(`Invalid route chunk name in "${resource}"`); - } - if (chunkName !== 'main' && !code.includes(chunkName)) { return { - code: emptyRouteChunkSnippet(), + code: chunk ?? emptyRouteChunkSnippet(), map: null, }; - } - - const chunk = await getRouteChunkIfEnabled( - routeChunkCache, - routeChunkConfig, - resourcePath, - chunkName, - code - ); + }); - if (splitRouteModules === 'enforce' && chunkName === 'main' && chunk) { - const exportNames = await getExportNames(chunk); - validateRouteChunks({ - config: routeChunkConfig, - id: resourcePath, - valid: buildEnforceChunkValidity(exportNames), - }); - } - - return { - code: chunk ?? emptyRouteChunkSnippet(), - map: null, - }; -}; +export const createRouteChunkArtifact = ( + options: RouteChunkArtifactOptions +): Promise => + runPluginEffect(createRouteChunkArtifactEffect(options)); diff --git a/src/route-export-resolution.ts b/src/route-export-resolution.ts index 8aa2bfd..23cb6f2 100644 --- a/src/route-export-resolution.ts +++ b/src/route-export-resolution.ts @@ -1,7 +1,9 @@ import { readFileSync, statSync, type Stats } from 'node:fs'; import { createRequire } from 'node:module'; +import { Effect } from 'effect'; import { dirname, relative, resolve } from 'pathe'; import { JS_EXTENSIONS, PLUGIN_NAME } from './constants.js'; +import { runPluginEffect } from './effect-runtime.js'; import { getExportNamesAndExportAll, getRouteModuleAnalysis, @@ -261,14 +263,23 @@ export type RouteModuleResolver = ( callback: RouteModuleResolveCallback ) => void; +const resolveBundlerRouteModuleEffect = ( + resolveModule: RouteModuleResolver, + specifier: string, + importerPath: string +): Effect.Effect => + Effect.async(resume => { + resolveModule(dirname(importerPath), specifier, (error, resolved) => { + resume(Effect.succeed(error || !resolved ? null : resolved)); + }); + }); + export const createBundlerRouteExportResolver = (resolveModule: RouteModuleResolver): RouteExportResolver => (specifier, importerPath) => - new Promise(resolveResolvedPath => { - resolveModule(dirname(importerPath), specifier, (error, resolved) => { - resolveResolvedPath(error || !resolved ? null : resolved); - }); - }); + runPluginEffect( + resolveBundlerRouteModuleEffect(resolveModule, specifier, importerPath) + ); export const collectClientOnlyStubExportNames = async ( code: string, diff --git a/src/route-watch.ts b/src/route-watch.ts index ec26780..5a495fd 100644 --- a/src/route-watch.ts +++ b/src/route-watch.ts @@ -1,11 +1,19 @@ import { watch, type FSWatcher } from 'node:fs'; import { access, mkdir, readdir, writeFile } from 'node:fs/promises'; import type { RsbuildConfig } from '@rsbuild/core'; +import { Duration, Effect, Fiber } from 'effect'; import { dirname, resolve } from 'pathe'; +import { getDefaultConcurrency } from './concurrency.js'; +import { runPluginEffect, tryPluginPromise } from './effect-runtime.js'; import type { Route } from './types.js'; const ROUTE_RESTART_MARKER_ASSET = '.react-router/route-watch'; const INITIAL_RESTART_MARKER_CONTENT = 'react-router-route-watch'; +const ROUTE_TOPOLOGY_RESCAN_DEBOUNCE_MS = 100; +const ROUTE_DIRECTORY_SCAN_CONCURRENCY = Math.max( + 1, + Math.min(16, getDefaultConcurrency() || 1) +); type RouteManifestSnapshotEntry = Pick< Route, @@ -111,32 +119,32 @@ const areSetsEqual = (left: Set, right: Set): boolean => { return true; }; -const readRouteDirectories = async ( +const readRouteDirectories = (watchDirectory: string): Promise> => { + return runPluginEffect(readRouteDirectoriesEffect(watchDirectory)); +}; + +const readRouteDirectoriesEffect = ( watchDirectory: string -): Promise> => { +): Effect.Effect, Error, never> => { const directories = new Set(); - - const walkDirectory = async (directory: string): Promise => { - let entries; - try { - entries = await readdir(directory, { withFileTypes: true }); - } catch { - return; - } - - directories.add(directory); - await Promise.all( - entries.map(async entry => { - const entryPath = resolve(directory, entry.name); - if (entry.isDirectory()) { - await walkDirectory(entryPath); - } - }) + const walkDirectory = (directory: string): Effect.Effect => + tryPluginPromise(() => readdir(directory, { withFileTypes: true })).pipe( + Effect.catchAll(() => Effect.succeed([])), + Effect.map(entries => { + directories.add(directory); + return entries + .filter(entry => entry.isDirectory()) + .map(entry => resolve(directory, entry.name)); + }), + Effect.flatMap(childDirectories => + Effect.forEach(childDirectories, walkDirectory, { + concurrency: ROUTE_DIRECTORY_SCAN_CONCURRENCY, + discard: true, + }) + ) ); - }; - await walkDirectory(watchDirectory); - return directories; + return walkDirectory(watchDirectory).pipe(Effect.as(directories)); }; export const createRouteTopologyWatcher = async ({ @@ -178,14 +186,19 @@ export const createRouteTopologyWatcher = async ({ routeTopology: initialRouteTopology ?? discoveredState.routeTopology, }; let closed = false; - let rescanTimer: ReturnType | undefined; + let scheduledRescanFiber: ReturnType | undefined; + let scheduledRescanToken: symbol | undefined; let rescanQueue = Promise.resolve(); const directoryWatchers = new Map(); - const touchRestartMarker = async (): Promise => { - await mkdir(dirname(restartMarkerPath), { recursive: true }); - await writeFile(restartMarkerPath, String(Date.now())); - }; + const touchRestartMarkerEffect = (): Effect.Effect => + tryPluginPromise(() => + mkdir(dirname(restartMarkerPath), { recursive: true }) + ).pipe( + Effect.zipRight( + tryPluginPromise(() => writeFile(restartMarkerPath, String(Date.now()))) + ) + ); const closeRemovedDirectoryWatchers = ( nextDirectories: Set @@ -224,74 +237,129 @@ export const createRouteTopologyWatcher = async ({ watchNewDirectories(nextDirectories); }; - const applyNextState = async (nextState: RouteDirectoryState) => { - if (closed) { - return; - } - syncDirectoryWatchers(nextState.directories); - if (!areSetsEqual(state.routeTopology, nextState.routeTopology)) { - if (onRouteTopologyChange) { - // This is a notification boundary, not part of the rescan - // transaction. A custom-server callback may close this watcher while - // replacing its compiler, so awaiting it here would deadlock close(). - state = nextState; - try { - void Promise.resolve(onRouteTopologyChange()).catch(onError); - } catch (error) { - onError(error); - } - return; - } else { - await touchRestartMarker(); - } + const applyNextStateEffect = ( + nextState: RouteDirectoryState + ): Effect.Effect => + Effect.suspend(() => { if (closed) { - return; + return Effect.void; + } + syncDirectoryWatchers(nextState.directories); + if (!areSetsEqual(state.routeTopology, nextState.routeTopology)) { + if (onRouteTopologyChange) { + // This is a notification boundary, not part of the rescan + // transaction. A custom-server callback may close this watcher while + // replacing its compiler, so awaiting it here would deadlock close(). + state = nextState; + return Effect.sync(() => { + try { + void Promise.resolve(onRouteTopologyChange()).catch(onError); + } catch (error) { + onError(error); + } + }); + } + return touchRestartMarkerEffect().pipe( + Effect.zipRight( + Effect.sync(() => { + if (!closed) { + state = nextState; + } + }) + ) + ); } state = nextState; - return; - } - state = nextState; - }; + return Effect.void; + }); - const runRescan = async (): Promise => { - if (closed) { - return; - } + const runRescanEffect = (): Effect.Effect => { let nextDirectories: Set | undefined; - try { - nextDirectories = await readRouteDirectories(watchDirectory); + return Effect.gen(function* () { + if (closed) { + return; + } + nextDirectories = yield* readRouteDirectoriesEffect(watchDirectory); if (closed) { return; } const nextState = { directories: nextDirectories, - routeTopology: await getRouteTopology(), + routeTopology: yield* tryPluginPromise(getRouteTopology), }; if (closed) { return; } - await applyNextState(nextState); - } catch (error) { - if (nextDirectories && !closed) { - syncDirectoryWatchers(nextDirectories); - } - onError(error); - } + yield* applyNextStateEffect(nextState); + }).pipe( + Effect.catchAll(error => + Effect.sync(() => { + if (nextDirectories && !closed) { + syncDirectoryWatchers(nextDirectories); + } + onError(error); + }) + ) + ); }; const rescan = (): Promise => { - rescanQueue = rescanQueue.then(runRescan, runRescan); + rescanQueue = rescanQueue.then( + () => runPluginEffect(runRescanEffect()), + () => runPluginEffect(runRescanEffect()) + ); return rescanQueue; }; + const cancelScheduledRescan = (): Promise => { + const fiber = scheduledRescanFiber; + scheduledRescanFiber = undefined; + scheduledRescanToken = undefined; + if (!fiber) { + return Promise.resolve(); + } + return runPluginEffect(Fiber.interrupt(fiber).pipe(Effect.asVoid)); + }; + const scheduleRescan = (): void => { - if (rescanTimer) { - clearTimeout(rescanTimer); + const previousFiber = scheduledRescanFiber; + if (previousFiber) { + void runPluginEffect( + Fiber.interrupt(previousFiber).pipe(Effect.asVoid) + ).catch(onError); } - rescanTimer = setTimeout(() => { - rescanTimer = undefined; - void rescan(); - }, 100); + + const token = Symbol(); + scheduledRescanToken = token; + scheduledRescanFiber = Effect.runFork( + Effect.sleep(Duration.millis(ROUTE_TOPOLOGY_RESCAN_DEBOUNCE_MS)).pipe( + Effect.zipRight( + Effect.suspend(() => { + if (closed || scheduledRescanToken !== token) { + return Effect.void; + } + return tryPluginPromise(rescan).pipe(Effect.asVoid); + }) + ), + Effect.catchAll(error => + Effect.sync(() => { + onError(error); + }) + ), + Effect.ensuring( + Effect.sync(() => { + if (scheduledRescanToken === token) { + scheduledRescanFiber = undefined; + scheduledRescanToken = undefined; + } + }) + ) + ) + ); + }; + + const applyNextState = async (nextState: RouteDirectoryState) => { + await runPluginEffect(applyNextStateEffect(nextState)); }; try { @@ -302,13 +370,12 @@ export const createRouteTopologyWatcher = async ({ return async () => { if (closed) { + await cancelScheduledRescan(); await rescanQueue; return; } closed = true; - if (rescanTimer) { - clearTimeout(rescanTimer); - } + await cancelScheduledRescan(); for (const watcher of directoryWatchers.values()) { watcher.close(); } diff --git a/src/server-utils.ts b/src/server-utils.ts index c189b83..8f55b9c 100644 --- a/src/server-utils.ts +++ b/src/server-utils.ts @@ -1,5 +1,11 @@ import { resolve } from 'pathe'; +import { Effect } from 'effect'; import type { ServerBuild } from 'react-router'; +import { + runPluginEffect, + tryPluginPromise, + tryPluginSync, +} from './effect-runtime.js'; import type { Route } from './types.js'; /** @@ -124,25 +130,31 @@ function isRouteDiscovery(value: unknown): boolean { ); } -async function resolveBuildExports( +function resolveBuildExportsEffect( build: Record -): Promise> { +): Effect.Effect, Error, never> { const resolved = { ...build }; - for (const key of Object.keys(build)) { - if (!RESOLVABLE_BUILD_EXPORTS.has(key)) { - continue; - } - const value = build[key]; - if (typeof value === 'function' && value.length === 0) { - const result = value(); - resolved[key] = isPromiseLike(result) ? await result : result; - continue; - } - if (isPromiseLike(value)) { - resolved[key] = await value; - } - } - return resolved; + return Effect.forEach( + Object.keys(build), + key => + Effect.gen(function* () { + if (!RESOLVABLE_BUILD_EXPORTS.has(key)) { + return; + } + const value = build[key]; + if (typeof value === 'function' && value.length === 0) { + const result = yield* tryPluginSync(() => value()); + resolved[key] = isPromiseLike(result) + ? yield* tryPluginPromise(() => result) + : result; + return; + } + if (isPromiseLike(value)) { + resolved[key] = yield* tryPluginPromise(() => value); + } + }), + { discard: true } + ).pipe(Effect.as(resolved)); } function isServerBuild(value: unknown): value is ServerBuild { @@ -164,47 +176,67 @@ function isServerBuild(value: unknown): value is ServerBuild { ); } -async function resolveServerBuildCandidate( +function resolveServerBuildCandidateEffect( candidate: unknown -): Promise { +): Effect.Effect { if (!isRecord(candidate)) { - return undefined; + return Effect.succeed(undefined); } - const resolved = await resolveBuildExports(candidate); - return isServerBuild(resolved) ? resolved : undefined; + return resolveBuildExportsEffect(candidate).pipe( + Effect.map(resolved => (isServerBuild(resolved) ? resolved : undefined)) + ); } -export async function resolveServerBuildModule( +export function resolveServerBuildModuleEffect( buildModule: unknown, source: string -): Promise { - const moduleValue = await buildModule; - const candidates = [() => moduleValue]; - if (isRecord(moduleValue)) { - if ('default' in moduleValue) { - candidates.push(() => moduleValue.default); - } - if ('module.exports' in moduleValue) { - candidates.push(() => moduleValue['module.exports']); +): Effect.Effect { + return Effect.gen(function* () { + const moduleValue = isPromiseLike(buildModule) + ? yield* tryPluginPromise(() => buildModule) + : buildModule; + const candidates = [() => moduleValue]; + if (isRecord(moduleValue)) { + if ('default' in moduleValue) { + candidates.push(() => moduleValue.default); + } + if ('module.exports' in moduleValue) { + candidates.push(() => moduleValue['module.exports']); + } } - } - for (const getCandidate of candidates) { - const candidate = await getCandidate(); - const serverBuild = await resolveServerBuildCandidate(candidate); - if (serverBuild) { - return serverBuild; + for (const getCandidate of candidates) { + const candidate = yield* tryPluginSync(getCandidate); + const serverBuild = yield* resolveServerBuildCandidateEffect(candidate); + if (serverBuild) { + return serverBuild; + } } - } - throw new Error( - `[rsbuild-plugin-react-router] ${source} did not contain a valid React Router ServerBuild.` - ); + return yield* Effect.fail( + new Error( + `[rsbuild-plugin-react-router] ${source} did not contain a valid React Router ServerBuild.` + ) + ); + }); +} + +export function resolveServerBuildModule( + buildModule: unknown, + source: string +): Promise { + return runPluginEffect(resolveServerBuildModuleEffect(buildModule, source)); +} + +export function resolveReactRouterServerBuildEffect( + buildModule: unknown +): Effect.Effect { + return resolveServerBuildModuleEffect(buildModule, 'Imported module'); } export function resolveReactRouterServerBuild( buildModule: unknown ): Promise { - return resolveServerBuildModule(buildModule, 'Imported module'); + return runPluginEffect(resolveReactRouterServerBuildEffect(buildModule)); } export { generateServerBuild }; diff --git a/src/typegen.ts b/src/typegen.ts index de10f5e..81d4b18 100644 --- a/src/typegen.ts +++ b/src/typegen.ts @@ -1,49 +1,162 @@ import type { RsbuildPluginAPI } from '@rsbuild/core'; import type { ResultPromise } from 'execa'; +import { Effect } from 'effect'; +import { + createDelayedPluginTask, + DEV_BACKGROUND_STARTUP_DELAY_MS, + runPluginEffect, + tryPluginPromise, + tryPluginSync, +} from './effect-runtime.js'; -export const registerReactRouterTypegen = (api: RsbuildPluginAPI): void => { +type Execa = typeof import('execa').execa; +type LoadExeca = () => Promise; + +export type ReactRouterTypegenRunner = { + startWatch(): Promise; + closeWatch(): Promise; + runBuild(): Promise; +}; + +const loadDefaultExeca: LoadExeca = async () => { + const { execa } = await import('execa'); + return execa; +}; + +export const createReactRouterTypegenRunner = ( + loadExeca: LoadExeca = loadDefaultExeca +): ReactRouterTypegenRunner => { let typegenProcess: ResultPromise | undefined; - api.onBeforeStartDevServer(async () => { - if (typegenProcess) { - return; - } - const { execa } = await import('execa'); - const process = execa( - 'npx', - ['--yes', 'react-router', 'typegen', '--watch'], - { - stdio: 'inherit', - detached: false, - cleanup: true, - } + const observeWatchExit = (process: ResultPromise): void => { + void runPluginEffect( + tryPluginPromise(() => process).pipe( + Effect.catchAll(() => Effect.void), + Effect.zipRight( + tryPluginSync(() => { + if (typegenProcess === process) { + typegenProcess = undefined; + } + }) + ) + ) ); - typegenProcess = process; - process - .catch(() => { - // Ignore errors when the process is killed on server shutdown. - }) - .finally(() => { - if (typegenProcess === process) { + }; + + return { + startWatch(): Promise { + return runPluginEffect( + tryPluginSync(() => typegenProcess).pipe( + Effect.flatMap(activeProcess => { + if (activeProcess) { + return Effect.void; + } + return tryPluginPromise(loadExeca).pipe( + Effect.flatMap(execa => + tryPluginSync(() => + execa( + 'npx', + ['--yes', 'react-router', 'typegen', '--watch'], + { + stdio: 'inherit', + detached: false, + cleanup: true, + } + ) + ) + ), + Effect.flatMap(process => + tryPluginSync(() => { + typegenProcess = process; + observeWatchExit(process); + }) + ) + ); + }) + ) + ); + }, + + closeWatch(): Promise { + return runPluginEffect( + tryPluginSync(() => { + const process = typegenProcess; typegenProcess = undefined; - } - }); - }); + return process; + }).pipe( + Effect.flatMap(process => { + if (!process) { + return Effect.void; + } + return tryPluginSync(() => { + process.kill('SIGTERM'); + }).pipe( + Effect.zipRight( + tryPluginPromise(() => process).pipe( + Effect.catchAll(() => Effect.void), + Effect.asVoid + ) + ) + ); + }) + ) + ); + }, + + runBuild(): Promise { + return runPluginEffect( + tryPluginPromise(loadExeca).pipe( + Effect.flatMap(execa => + tryPluginPromise(() => + execa('npx', ['--yes', 'react-router', 'typegen'], { + stdio: 'inherit', + }) + ) + ), + Effect.asVoid + ) + ); + }, + }; +}; - api.onCloseDevServer(async () => { - const process = typegenProcess; - typegenProcess = undefined; - if (!process) { - return; - } - process.kill('SIGTERM'); - await process.catch(() => undefined); +export const registerReactRouterTypegen = ( + api: RsbuildPluginAPI, + runner: ReactRouterTypegenRunner = createReactRouterTypegenRunner(), + devWatchDelayMs: number = DEV_BACKGROUND_STARTUP_DELAY_MS +): void => { + let devWatchScheduled = false; + const devWatchTask = createDelayedPluginTask({ + delayMs: devWatchDelayMs, + run: () => tryPluginPromise(() => runner.startWatch()).pipe(Effect.asVoid), + onError(error) { + api.logger.warn( + `[react-router] Failed to start React Router typegen watch: ${error}` + ); + }, }); - api.onBeforeBuild(async () => { - const { execa } = await import('execa'); - await execa('npx', ['--yes', 'react-router', 'typegen'], { - stdio: 'inherit', + if (api.context.action !== 'build') { + api.onAfterDevCompile(() => { + if (devWatchScheduled) { + return; + } + devWatchScheduled = true; + devWatchTask.schedule(); }); - }); + } + + api.onCloseDevServer(() => + runPluginEffect( + devWatchTask + .cancelEffect() + .pipe( + Effect.zipRight( + tryPluginPromise(() => runner.closeWatch()).pipe(Effect.asVoid) + ) + ) + ) + ); + + api.onBeforeBuild(() => runner.runBuild()); }; diff --git a/tests/build-manifest.test.ts b/tests/build-manifest.test.ts index 5361c74..d70d14e 100644 --- a/tests/build-manifest.test.ts +++ b/tests/build-manifest.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from '@rstest/core'; +import { runPluginEffect } from '../src/effect-runtime'; import type { Config } from '../src/react-router-config'; import { getBuildManifest, + getBuildManifestEffect, getRoutesByServerBundleId, } from '../src/build-manifest'; @@ -60,6 +62,40 @@ describe('build manifest', () => { expect(bundleRoutes['routes/about'].file).toBe('routes/about.tsx'); }); + it('builds server bundle mapping through the Effect path', async () => { + const routes = { + root: { id: 'root', file: 'root.tsx', path: '' }, + 'routes/about': { + id: 'routes/about', + parentId: 'root', + file: 'routes/about.tsx', + path: 'about', + }, + }; + + const serverBundles: Config['serverBundles'] = async ({ branch }) => { + return `bundle_${branch.length}`; + }; + + const result = await runPluginEffect( + getBuildManifestEffect({ + reactRouterConfig: { + appDirectory: 'app', + buildDirectory: 'build', + serverBuildFile: 'index.js', + future: {}, + serverBundles, + }, + routes, + rootDirectory: process.cwd(), + }) + ); + + const bundleRoutes = getRoutesByServerBundleId(result, routes).bundle_2; + expect(bundleRoutes.root.file).toBe('root.tsx'); + expect(bundleRoutes['routes/about'].file).toBe('routes/about.tsx'); + }); + it('validates server bundle IDs based on vite environment API flag', async () => { const routes = { root: { id: 'root', file: 'root.tsx', path: '' }, diff --git a/tests/client-modules.test.ts b/tests/client-modules.test.ts index 1f952a2..b985a33 100644 --- a/tests/client-modules.test.ts +++ b/tests/client-modules.test.ts @@ -5,7 +5,10 @@ import { resolve } from 'pathe'; import { describe, expect, it } from '@rstest/core'; import { createStubRsbuild } from '@scripts/test-helper'; import { pluginReactRouter } from '../src'; -import { collectClientOnlyStubExportNames } from '../src/route-export-resolution'; +import { + collectClientOnlyStubExportNames, + createBundlerRouteExportResolver, +} from '../src/route-export-resolution'; describe('client-only module transforms', () => { const createConditionalClientPackage = async ( @@ -225,4 +228,38 @@ describe('client-only module transforms', () => { await rm(root, { recursive: true, force: true }); } }); + + it('adapts the Rsbuild callback resolver to the route export resolver API', async () => { + const routeResolver = createBundlerRouteExportResolver( + (context, specifier, callback) => { + expect(context).toBe('/app/routes'); + expect(specifier).toBe('@client/exports'); + callback(null, '/app/generated/client-exports.ts'); + } + ); + + await expect( + routeResolver('@client/exports', '/app/routes/example.client.ts') + ).resolves.toBe('/app/generated/client-exports.ts'); + }); + + it('treats bundler resolver errors and false results as unresolved', async () => { + const failedResolver = createBundlerRouteExportResolver( + (_context, _specifier, callback) => { + callback(new Error('not found')); + } + ); + const falseResolver = createBundlerRouteExportResolver( + (_context, _specifier, callback) => { + callback(null, false); + } + ); + + await expect( + failedResolver('@client/missing', '/app/routes/example.client.ts') + ).resolves.toBeNull(); + await expect( + falseResolver('@client/false', '/app/routes/example.client.ts') + ).resolves.toBeNull(); + }); }); diff --git a/tests/dev-generation.test.ts b/tests/dev-generation.test.ts index 9af9446..b0f6d57 100644 --- a/tests/dev-generation.test.ts +++ b/tests/dev-generation.test.ts @@ -2,6 +2,7 @@ import type { RsbuildDevServer, Rspack } from '@rsbuild/core'; import { describe, expect, it, rstest } from '@rstest/core'; import type { ServerBuild } from 'react-router'; import { loadReactRouterServerBuild } from '../src'; +import { evaluateServerBuildsEffect } from '../src/dev-runtime-artifacts'; import { createReactRouterDevRuntime, registerReactRouterDevRuntime, @@ -11,6 +12,7 @@ import { type ReactRouterDevManifest, type ReactRouterDevRuntime, } from '../src/dev-generation'; +import { runPluginEffect } from '../src/effect-runtime'; const noKnownChanges: DevGraphChanges = { web: { known: false, files: new Set() }, @@ -174,6 +176,25 @@ const createHarness = ( }; describe('React Router development runtime', () => { + it('evaluates server builds through the Effect path', async () => { + const defaultBuild = createBuild('default'); + const nestedBuild = createBuild('nested'); + const loadBundle = rstest.fn((entryName: string) => + entryName === 'nested/entry' ? Promise.resolve(nestedBuild) : defaultBuild + ); + const server = { + environments: { node: { loadBundle } }, + } as unknown as RsbuildDevServer; + + const builds = await runPluginEffect( + evaluateServerBuildsEffect(server, ['nested/entry', 'static/js/app']) + ); + + expect(builds['nested/entry']).toMatchObject({ marker: 'nested' }); + expect(builds['static/js/app']).toMatchObject({ marker: 'default' }); + expect(loadBundle).toHaveBeenCalledTimes(2); + }); + it('publishes a validated server build pinned to its exact web manifest', async () => { const rawBuild = createBuild('raw'); const { runtime } = createHarness(() => rawBuild); @@ -598,6 +619,45 @@ describe('React Router development runtime', () => { }); }); + it('resolves all initial waiters from one committed generation', async () => { + const { loadBundle, runtime } = createHarness(() => + createBuild('shared') + ); + const web = createCompilation('web'); + const node = createCompilation('node'); + + runtime.beginAttempt(); + captureWeb(runtime, web, 'shared'); + const firstWaiting = runtime.load(); + const secondWaiting = runtime.load(); + await runtime.finishAttempt( + createGraphStats(web, node), + noKnownChanges, + graphIdentity(web, node) + ); + + const [first, second] = await Promise.all([firstWaiting, secondWaiting]); + expect(first).toBe(second); + expect(loadBundle).toHaveBeenCalledTimes(1); + }); + + it('rejects all initial waiters when the runtime closes', async () => { + const { errors, runtime } = createHarness(() => createBuild('closed')); + + runtime.beginAttempt(); + const firstWaiting = runtime.load(); + const secondWaiting = runtime.load(); + runtime.close(); + + await expect(firstWaiting).rejects.toThrow( + 'development server closed before a React Router build was ready' + ); + await expect(secondWaiting).rejects.toThrow( + 'development server closed before a React Router build was ready' + ); + expect(errors).toEqual([]); + }); + it('rejects initial waiters on a fatal compiler failure and recovers', async () => { const { loadBundle, runtime } = createHarness(() => createBuild('recovered') diff --git a/tests/effect-runtime.test.ts b/tests/effect-runtime.test.ts new file mode 100644 index 0000000..65fd2fc --- /dev/null +++ b/tests/effect-runtime.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, rstest } from '@rstest/core'; +import { Effect } from 'effect'; +import { + createDelayedPluginTask, + runPluginEffect, + tryPluginSync, +} from '../src/effect-runtime'; + +describe('effect runtime helpers', () => { + it('preserves typed errors at promise boundaries', async () => { + const error = new Error('typed failure'); + + await expect(runPluginEffect(tryPluginSync(() => { + throw error; + }))).rejects.toBe(error); + }); + + it('normalizes synchronous thrown causes to errors', async () => { + await expect( + runPluginEffect( + tryPluginSync(() => { + throw 'dev runtime failed'; + }) + ) + ).rejects.toThrow('dev runtime failed'); + }); + + it('runs delayed plugin tasks after their delay', async () => { + const run = rstest.fn(); + const task = createDelayedPluginTask({ + delayMs: 10, + run: () => Effect.sync(run), + onError: error => { + throw error; + }, + }); + + task.schedule(); + expect(run).not.toHaveBeenCalled(); + await expect.poll(() => run.mock.calls.length, { timeout: 1000 }).toBe(1); + }); + + it('cancels delayed plugin tasks before they start', async () => { + const run = rstest.fn(); + const task = createDelayedPluginTask({ + delayMs: 1000, + run: () => Effect.sync(run), + onError: error => { + throw error; + }, + }); + + task.schedule(); + await task.cancel(); + await new Promise(resolve => setTimeout(resolve, 20)); + + expect(run).not.toHaveBeenCalled(); + }); + + it('supports Effect-based cancellation for delayed plugin tasks', async () => { + const run = rstest.fn(); + const task = createDelayedPluginTask({ + delayMs: 1000, + run: () => Effect.sync(run), + onError: error => { + throw error; + }, + }); + + task.schedule(); + await runPluginEffect(task.cancelEffect()); + await new Promise(resolve => setTimeout(resolve, 20)); + + expect(run).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts index 17b8ead..738f45a 100644 --- a/tests/manifest.test.ts +++ b/tests/manifest.test.ts @@ -2,10 +2,12 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from '@rstest/core'; +import { runPluginEffect } from '../src/effect-runtime'; import { createReactRouterManifestStats, configRoutesToRouteManifest, configRoutesToRouteManifestEntries, + generateReactRouterManifestForDevEffect, generateReactRouterManifestForDev, getReactRouterManifestForDev, getReactRouterManifestChunkNames, @@ -380,6 +382,39 @@ describe('manifest', () => { } }); + it('generates manifests through the Effect API', async () => { + const { root, appDir } = createTempApp(` + export async function loader() { return null; } + export default function Page() { return null; } + `); + try { + const { manifest, moduleExportsByRouteId } = await runPluginEffect( + generateReactRouterManifestForDevEffect( + routes, + {}, + clientStats, + appDir, + '/', + { + isBuild: true, + rootRouteFile: 'root.tsx', + splitRouteModules: false, + } + ) + ); + + expect(manifest.routes['routes/page']).toMatchObject({ + hasLoader: true, + hasDefaultExport: true, + }); + expect(moduleExportsByRouteId['routes/page']).toEqual( + expect.arrayContaining(['loader', 'default']) + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it('preserves dev css fallback when route analysis uses transformed code', async () => { const { root, appDir } = createTempApp(` import './page.css'; diff --git a/tests/parallel-route-transforms.test.ts b/tests/parallel-route-transforms.test.ts index fdc4fe5..8e4c22e 100644 --- a/tests/parallel-route-transforms.test.ts +++ b/tests/parallel-route-transforms.test.ts @@ -3,6 +3,7 @@ import { mapWithConcurrency } from '../src/concurrency'; import { getExportNames } from '../src/export-utils'; import { executeRouteTransformTask, + type RouteTransformResult, type RouteModuleTransformTask, } from '../src/route-transform-tasks'; import { @@ -53,6 +54,7 @@ type FakeWorkerHandler = (value: any) => void; class FakeRouteTransformWorker { readonly messages: WorkerRequest[] = []; readonly handlers = new Map(); + failNextPostMessage = false; terminateCalls = 0; on(event: string, handler: FakeWorkerHandler): this { @@ -61,6 +63,10 @@ class FakeRouteTransformWorker { } postMessage(message: WorkerRequest): void { + if (this.failNextPostMessage) { + this.failNextPostMessage = false; + throw new Error('postMessage failed'); + } this.messages.push(message); } @@ -76,6 +82,18 @@ class FakeRouteTransformWorker { } } +const resolveWorkerMessage = ( + worker: FakeRouteTransformWorker, + result: RouteTransformResult, + messageIndex = worker.messages.length - 1 +): void => { + worker.emit('message', { + id: worker.messages[messageIndex]!.id, + ok: true, + result, + } satisfies WorkerResponse); +}; + describe('parallel route transforms', () => { it.each([ [1, 0], @@ -162,17 +180,66 @@ describe('parallel route transforms', () => { const pending = executor.run(createRouteModuleTask()); expect(createdWorkers).toBe(1); - worker.emit('message', { - id: worker.messages[0]!.id, - ok: true, - result: { code: 'created lazily' }, - } satisfies WorkerResponse); + resolveWorkerMessage(worker, { code: 'created lazily' }); await expect(pending).resolves.toEqual({ code: 'created lazily' }); await executor.close(); expect(worker.terminateCalls).toBe(1); }); + it('rejects in-flight worker tasks on idempotent close and runs inline afterward', async () => { + const worker = new FakeRouteTransformWorker(); + const executor = createRouteTransformExecutorForTesting( + { + parallelTransforms: 1, + }, + () => worker + ); + + const pending = executor.run(createRouteModuleTask()); + expect(worker.messages).toHaveLength(1); + + const firstClose = executor.close(); + const secondClose = executor.close(); + + await expect(pending).rejects.toThrow('Route transform worker closed.'); + await expect(Promise.all([firstClose, secondClose])).resolves.toEqual([ + undefined, + undefined, + ]); + expect(worker.terminateCalls).toBe(1); + + const inlineResult = await executor.run(createRouteModuleTask()); + expect(inlineResult.code).toContain('export default _withComponentProps'); + expect(worker.messages).toHaveLength(1); + }); + + it('sends full source again after a cached worker request fails to post', async () => { + const worker = new FakeRouteTransformWorker(); + const executor = createRouteTransformExecutorForTesting( + { + parallelTransforms: 1, + }, + () => worker + ); + const task = createRouteModuleTask(); + + const firstRun = executor.run(task); + expect(worker.messages[0]?.task.code).toBe(task.code); + resolveWorkerMessage(worker, { code: 'first' }, 0); + await expect(firstRun).resolves.toEqual({ code: 'first' }); + + worker.failNextPostMessage = true; + await expect(executor.run(task)).rejects.toThrow('postMessage failed'); + + const thirdRun = executor.run(task); + expect(worker.messages[1]?.task.code).toBe(task.code); + resolveWorkerMessage(worker, { code: 'third' }, 1); + await expect(thirdRun).resolves.toEqual({ code: 'third' }); + + await executor.close(); + }); + it('executes route client entry tasks through the shared task executor', async () => { await expect( executeRouteTransformTask({ diff --git a/tests/performance.test.ts b/tests/performance.test.ts index ab9179d..13e9b4c 100644 --- a/tests/performance.test.ts +++ b/tests/performance.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from '@rstest/core'; +import { Effect } from 'effect'; +import { runPluginEffect } from '../src/effect-runtime'; import { createReactRouterPerformanceProfiler } from '../src/performance'; const parsePerformanceReport = (message: string) => { @@ -202,6 +204,30 @@ describe('React Router performance profiler', () => { expect(report.operations['route:module'].count).toBe(1); }); + it('records Effect operations and preserves failures', async () => { + const logs: string[] = []; + const profiler = createReactRouterPerformanceProfiler({ + enabled: true, + log: message => logs.push(message), + }); + const failure = new Error('effect operation failed'); + + await expect( + runPluginEffect( + profiler.recordEffect( + 'web', + 'route:module', + 'app/routes/effect.tsx', + Effect.fail(failure) + ) + ) + ).rejects.toBe(failure); + profiler.flush('web'); + + const report = parsePerformanceReport(logs[0]); + expect(report.operations['route:module'].count).toBe(1); + }); + it('does not evaluate timers or log output when disabled', async () => { const logs: string[] = []; const originalNow = performance.now; diff --git a/tests/prerender.test.ts b/tests/prerender.test.ts index a9ae9db..0ff5dc6 100644 --- a/tests/prerender.test.ts +++ b/tests/prerender.test.ts @@ -1,4 +1,11 @@ import { describe, expect, it } from '@rstest/core'; +import { Effect } from 'effect'; +import { + createBoundedPrerenderTasksEffect, + createBuildRequestEffect, + runBoundedPrerenderTasks, + withBuildRequest, +} from '../src/prerender-build'; import { createPrerenderRoutes, getPrerenderConcurrency, @@ -7,8 +14,8 @@ import { normalizePrerenderMatchPath, resolvePrerenderPaths, validatePrerenderConfig, - withBuildRequest, } from '../src/prerender'; +import { runPluginEffect } from '../src/effect-runtime'; import type { RouteConfigEntry } from '@react-router/dev/routes'; const routes: RouteConfigEntry[] = [ @@ -169,6 +176,22 @@ describe('prerender helpers', () => { expect(signal?.aborted).toBe(true); }); + it('aborts effect build request signals after the handler rejects', async () => { + const failure = new Error('prerender handler failed'); + let signal: AbortSignal | undefined; + + await expect( + runPluginEffect( + createBuildRequestEffect('http://localhost/about', undefined, request => { + signal = request.signal; + throw failure; + }) + ) + ).rejects.toBe(failure); + + expect(signal?.aborted).toBe(true); + }); + it('returns no ssr:false prerender export errors for valid prerendered routes', () => { const manifestRoutes = { root: { id: 'root', file: 'root.tsx', path: '' }, @@ -329,3 +352,76 @@ describe('prerender helpers', () => { ); }); }); + +describe('prerender build scheduler', () => { + it('runs prerender task effects with a concurrency cap', async () => { + let active = 0; + let maxActive = 0; + const completed: string[] = []; + + await runPluginEffect( + createBoundedPrerenderTasksEffect( + ['/slow', '/fast', '/medium'], + 2, + path => + Effect.promise(async () => { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise(resolve => + setTimeout(resolve, path === '/slow' ? 15 : 1) + ); + completed.push(path); + active -= 1; + }) + ) + ); + + expect(maxActive).toBeLessThanOrEqual(2); + expect(completed.sort()).toEqual(['/fast', '/medium', '/slow']); + }); + + it('runs prerender tasks with a concurrency cap', async () => { + let active = 0; + let maxActive = 0; + const completed: string[] = []; + + await runBoundedPrerenderTasks( + ['/slow', '/fast', '/medium'], + 2, + async path => { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise(resolve => + setTimeout(resolve, path === '/slow' ? 15 : 1) + ); + completed.push(path); + active -= 1; + } + ); + + expect(maxActive).toBeLessThanOrEqual(2); + expect(completed.sort()).toEqual(['/fast', '/medium', '/slow']); + }); + + it('rejects without starting later prerender tasks after an early failure', async () => { + const started: string[] = []; + + await expect( + runBoundedPrerenderTasks( + ['/fail', '/slow', '/later'], + 2, + async path => { + started.push(path); + await new Promise(resolve => + setTimeout(resolve, path === '/fail' ? 1 : 15) + ); + if (path === '/fail') { + throw new Error('prerender failed'); + } + } + ) + ).rejects.toThrow('prerender failed'); + + expect(started).toEqual(['/fail', '/slow']); + }); +}); diff --git a/tests/react-router-config.test.ts b/tests/react-router-config.test.ts index 6269041..61dbca9 100644 --- a/tests/react-router-config.test.ts +++ b/tests/react-router-config.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from '@rstest/core'; -import { resolveReactRouterConfig } from '../src/react-router-config'; +import { + resolveReactRouterConfig, + resolveReactRouterConfigEffect, +} from '../src/react-router-config'; +import { runPluginEffect } from '../src/effect-runtime'; describe('resolveReactRouterConfig', () => { it('merges presets and combines buildEnd hooks', async () => { @@ -31,6 +35,36 @@ describe('resolveReactRouterConfig', () => { expect(buildEndCalls).toBe(2); }); + it('resolves presets through the Effect config path', async () => { + let buildEndCalls = 0; + const result = await runPluginEffect( + resolveReactRouterConfigEffect({ + presets: [ + { + name: 'preset-a', + reactRouterConfig: async () => ({ + basename: '/effect-preset', + buildEnd: async () => { + buildEndCalls += 1; + }, + }), + }, + ], + buildEnd: async () => { + buildEndCalls += 1; + }, + }) + ); + + expect(result.resolved.basename).toBe('/effect-preset'); + await result.resolved.buildEnd?.({ + buildManifest: { routes: {} }, + reactRouterConfig: result.resolved, + viteConfig: {} as any, + }); + expect(buildEndCalls).toBe(2); + }); + it('preserves server bundle selection in SSR mode', async () => { const serverBundles = async () => 'bundle'; diff --git a/tests/route-artifacts.test.ts b/tests/route-artifacts.test.ts index f32d094..f86fdef 100644 --- a/tests/route-artifacts.test.ts +++ b/tests/route-artifacts.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from '@rstest/core'; +import { runPluginEffect } from '../src/effect-runtime'; import { createRouteChunkArtifact, + createRouteChunkArtifactEffect, createRouteClientEntryArtifact, + createRouteClientEntryArtifactEffect, } from '../src/route-artifacts'; import { emptyRouteChunkSnippet, @@ -132,6 +135,27 @@ describe('route artifact helpers', () => { )};`, }); }); + + it('generates route reexports through the Effect API', async () => { + const result = await runPluginEffect( + createRouteClientEntryArtifactEffect({ + code: ` + export async function clientLoader() { return null; } + export default function Route() { return null; } + `, + resourcePath, + environmentName: 'web', + isBuild: false, + routeChunkConfig: disabledRouteChunkConfig, + }) + ); + + expect(result).toEqual({ + code: `export { clientLoader, default } from ${JSON.stringify( + routeRequest + )};`, + }); + }); }); describe('createRouteChunkArtifact', () => { @@ -184,6 +208,34 @@ describe('route artifact helpers', () => { expect(result).toEqual({ code: expectedCode, map: null }); }); + it('generates route chunks through the Effect API', async () => { + const source = ` + export const clientAction = async () => {}; + export default function Route() { return null; } + `; + const cache: RouteChunkCache = new Map(); + const expectedCode = await getRouteChunkIfEnabled( + cache, + routeChunkConfig, + resourcePath, + 'clientAction', + source + ); + + const result = await runPluginEffect( + createRouteChunkArtifactEffect({ + code: source, + resource: getRouteChunkModuleId(resourcePath, 'clientAction'), + resourcePath, + routeChunkConfig, + routeChunkCache: cache, + isBuild: true, + }) + ); + + expect(result).toEqual({ code: expectedCode, map: null }); + }); + it('skips ESM transforms for named chunks when no route chunk exports exist', async () => { await expect( createRouteChunkArtifact({ diff --git a/tests/server-utils.test.ts b/tests/server-utils.test.ts index 5547ffe..b8b718e 100644 --- a/tests/server-utils.test.ts +++ b/tests/server-utils.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from '@rstest/core'; import type { ServerBuild } from 'react-router'; import { resolveReactRouterServerBuild } from '../src'; +import { runPluginEffect } from '../src/effect-runtime'; +import { resolveReactRouterServerBuildEffect } from '../src/server-utils'; const createBuild = (version: string): ServerBuild => ({ @@ -71,6 +73,19 @@ describe('resolveReactRouterServerBuild', () => { ).resolves.toMatchObject({ assets: { version: 'async' } }); }); + it('resolves server builds through the Effect path', async () => { + const build = createBuild('effect'); + + await expect( + runPluginEffect( + resolveReactRouterServerBuildEffect({ + ...build, + assets: async () => build.assets, + }) + ) + ).resolves.toMatchObject({ assets: { version: 'effect' } }); + }); + it('rejects modules without a React Router server build', async () => { await expect( resolveReactRouterServerBuild({ default: { routes: {} } }) diff --git a/tests/typegen.test.ts b/tests/typegen.test.ts new file mode 100644 index 0000000..d016a74 --- /dev/null +++ b/tests/typegen.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, rstest } from '@rstest/core'; +import type { ResultPromise } from 'execa'; +import { + createReactRouterTypegenRunner, + registerReactRouterTypegen, + type ReactRouterTypegenRunner, +} from '../src/typegen'; + +const createProcess = () => { + let rejectProcess!: (error: Error) => void; + const process = new Promise((_resolve, reject) => { + rejectProcess = reject; + }) as ResultPromise; + process.kill = rstest.fn(() => { + rejectProcess(new Error('killed')); + return true; + }); + return { process, rejectProcess }; +}; + +describe('React Router typegen runner', () => { + it('starts one watch process and kills it on close', async () => { + const first = createProcess(); + const second = createProcess(); + const execa = rstest + .fn() + .mockReturnValueOnce(first.process) + .mockReturnValueOnce(second.process); + const runner = createReactRouterTypegenRunner(async () => execa); + + await runner.startWatch(); + await runner.startWatch(); + expect(execa).toHaveBeenCalledTimes(1); + + await runner.closeWatch(); + expect(first.process.kill).toHaveBeenCalledWith('SIGTERM'); + + await runner.startWatch(); + expect(execa).toHaveBeenCalledTimes(2); + }); + + it('clears a watch process after it exits by itself', async () => { + const first = createProcess(); + const second = createProcess(); + const execa = rstest + .fn() + .mockReturnValueOnce(first.process) + .mockReturnValueOnce(second.process); + const runner = createReactRouterTypegenRunner(async () => execa); + + await runner.startWatch(); + first.rejectProcess(new Error('watch exited')); + await new Promise(resolve => setImmediate(resolve)); + + await runner.startWatch(); + expect(execa).toHaveBeenCalledTimes(2); + }); + + it('runs one-shot build typegen through npx', async () => { + const execa = rstest.fn().mockResolvedValue(undefined); + const runner = createReactRouterTypegenRunner(async () => execa); + + await runner.runBuild(); + + expect(execa).toHaveBeenCalledWith( + 'npx', + ['--yes', 'react-router', 'typegen'], + { stdio: 'inherit' } + ); + }); + + it('starts dev watch after the first dev compile without blocking startup', async () => { + let afterDevCompile!: () => void; + const startWatch = rstest.fn().mockResolvedValue(undefined); + const runner: ReactRouterTypegenRunner = { + startWatch, + closeWatch: rstest.fn().mockResolvedValue(undefined), + runBuild: rstest.fn().mockResolvedValue(undefined), + }; + const api = { + context: { action: 'dev' }, + logger: { warn: rstest.fn() }, + onAfterDevCompile: rstest.fn(callback => { + afterDevCompile = callback; + }), + onBeforeStartDevServer: rstest.fn(), + onCloseDevServer: rstest.fn(), + onBeforeBuild: rstest.fn(), + }; + + registerReactRouterTypegen(api as never, runner, 0); + + expect(api.onBeforeStartDevServer).not.toHaveBeenCalled(); + const result = afterDevCompile(); + expect(result).toBeUndefined(); + afterDevCompile(); + expect(startWatch).not.toHaveBeenCalled(); + await expect.poll(() => startWatch.mock.calls.length).toBe(1); + }); + + it('cancels delayed dev watch startup on close', async () => { + let afterDevCompile!: () => void; + let closeDevServer!: () => Promise; + const startWatch = rstest.fn().mockResolvedValue(undefined); + const closeWatch = rstest.fn().mockResolvedValue(undefined); + const runner: ReactRouterTypegenRunner = { + startWatch, + closeWatch, + runBuild: rstest.fn().mockResolvedValue(undefined), + }; + const api = { + context: { action: 'dev' }, + logger: { warn: rstest.fn() }, + onAfterDevCompile: rstest.fn(callback => { + afterDevCompile = callback; + }), + onBeforeStartDevServer: rstest.fn(), + onCloseDevServer: rstest.fn(callback => { + closeDevServer = callback; + }), + onBeforeBuild: rstest.fn(), + }; + + registerReactRouterTypegen(api as never, runner, 1000); + + afterDevCompile(); + await closeDevServer(); + await new Promise(resolve => setTimeout(resolve, 20)); + + expect(startWatch).not.toHaveBeenCalled(); + expect(closeWatch).toHaveBeenCalledTimes(1); + }); + + it('does not register the dev watch hook during production builds', () => { + const runner: ReactRouterTypegenRunner = { + startWatch: rstest.fn().mockResolvedValue(undefined), + closeWatch: rstest.fn().mockResolvedValue(undefined), + runBuild: rstest.fn().mockResolvedValue(undefined), + }; + const api = { + context: { action: 'build' }, + logger: { warn: rstest.fn() }, + onAfterDevCompile: rstest.fn(), + onBeforeStartDevServer: rstest.fn(), + onCloseDevServer: rstest.fn(), + onBeforeBuild: rstest.fn(), + }; + + registerReactRouterTypegen(api as never, runner); + + expect(api.onAfterDevCompile).not.toHaveBeenCalled(); + expect(api.onBeforeBuild).toHaveBeenCalled(); + }); +});