Skip to content

Feat: integrate SSR toolkit workflow with module-runner runtime and Vue SSR examples - #2277

Open
NidMo wants to merge 8 commits into
mainfrom
feat/ssr-toolkit
Open

Feat: integrate SSR toolkit workflow with module-runner runtime and Vue SSR examples#2277
NidMo wants to merge 8 commits into
mainfrom
feat/ssr-toolkit

Conversation

@NidMo

@NidMo NidMo commented Feb 23, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Integrate a full SSR toolkit workflow for Farm: dev/build/preview + middleware embedding path.
  • Land module-runner runtime/rust bridge updates in core.
  • Fix FARM-BUG-008 (updateModules return shape mismatch).
  • Add complete SSR examples (middleware-mode, ssr-toolkit) for integration usage.

Scope

  • crates/compiler, crates/node runtime bridge updates
  • packages/core module-runner and dev update path updates
  • packages/ssr new SSR toolkit package and tests
  • packages/cli SSR command integration
  • examples/middleware-mode and examples/ssr-toolkit

Feature Highlights

  • Added integrated SSR command pipeline in CLI (farm ssr dev/build/preview).
  • Preserved middleware-mode embedding path for external host servers.
  • Added preview-side server CSS import fallback handling in SSR toolkit.
  • Added Vue + Router + Less SSR example project for end-to-end integration reference.

Bug Fixes

  • FARM-BUG-008
    • Fixed vite-plugin-adapter updateModules return contract from string[] to tuple list [moduleId, updateType][].
    • Added merge priority for duplicated update types: removed > updated > added.
    • Added regression tests:
      • packages/core/src/plugin/js/vite-plugin-adapter.spec.ts

Validation

  • pnpm --filter @farmfe/core type-check
  • pnpm vitest run -c vitest.config.ts packages/core/src/plugin/js/vite-plugin-adapter.spec.ts packages/core/tests/module-runner.spec.ts
    • 87 passed
  • pnpm vitest run -c vitest.config.ts packages/ssr/test/build-preview.spec.ts packages/ssr/test/dev-server.spec.ts packages/ssr/test/server.spec.ts packages/ssr/test/ssr-toolkit-example.spec.ts packages/ssr/test/command.spec.ts packages/cli/src/argv.spec.ts packages/cli/src/ssr.spec.ts
    • 45 passed

Summary by CodeRabbit

Release Notes

  • New Features

    • Added comprehensive Server-Side Rendering (SSR) support with ssr dev, ssr build, and ssr preview CLI commands
    • Introduced module runner for dynamic module loading with in-memory caching and multiple evaluator types
    • Added new examples demonstrating SSR toolkit and middleware mode integration
    • Enhanced module resolution with path sanitization and improved handling of missing modules
  • Bug Fixes

    • Fixed module update path filtering to prevent processing of empty paths
    • Added guards to prevent panics when child modules are absent from the module graph

@vercel

vercel Bot commented Feb 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
farm-docs Ready Ready Preview, Comment Mar 19, 2026 3:39pm

@NidMo
NidMo requested a review from wre232114 February 23, 2026 15:00
@changeset-bot

changeset-bot Bot commented Feb 23, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 1f8b437

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

💥 An error occurred when fetching the changed packages and changesets in this PR
Some errors occurred when validating the changesets config:
The package or glob expression "farm-docs" is specified in the `ignore` option but it is not found in the project. You may have misspelled the package name or provided an invalid glob expression. Note that glob expressions must be defined according to https://www.npmjs.com/package/micromatch.
The package or glob expression "bench" is specified in the `ignore` option but it is not found in the project. You may have misspelled the package name or provided an invalid glob expression. Note that glob expressions must be defined according to https://www.npmjs.com/package/micromatch.

@coderabbitai

coderabbitai Bot commented Feb 23, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Introduces a comprehensive module runner system for server-side module evaluation, extensive SSR framework with CLI commands (dev/build/preview), module fetch capabilities with transform caching, and safety improvements to module update path handling with sanitization and guards.

Changes

Cohort / File(s) Summary
Compiler Module Update Safety
crates/compiler/src/update/handle_update_modules.rs, crates/node/src/plugin_adapters/js_plugin_adapter/mod.rs
Added path sanitization to trim whitespace and remove empty entries; guard for missing child modules to prevent panics; optional checks when removing from module graphs; preserves UpdateType in final results; includes tests for empty path filtering and missing module safety.
Node Layer Fetch Module
crates/node/Cargo.toml, crates/node/src/lib.rs
Adds swc TypeScript transform dependency; introduces RunnerTransformCacheStore for caching per-module transformed code; implements public JsFetchModuleOptions/JsFetchModuleResult types; adds full fetch_module workflow with caching, transformation, URL normalization, and inlined vs. external fetch handling; extensive utility functions for path/URL resolution and source map support.
CLI SSR Command Support
packages/cli/src/argv.ts, packages/cli/src/argv.spec.ts, packages/cli/src/index.ts, packages/cli/src/ssr.ts, packages/cli/src/ssr.spec.ts, packages/cli/src/types.ts, packages/cli/src/utils.ts
Adds CLI argument normalization for SSR commands (dev/build/preview); introduces CliSsrOptions type; implements resolveSsrRunOptions to derive SSR configuration from CLI flags with precedence rules; supports config file resolution, port parsing, template/entry resolution; wires new SSR commands into CLI with per-command option schemas.
Core Binding & Types
packages/core/binding/binding.d.ts, packages/core/binding/index.js, packages/core/src/compiler/index.ts, packages/core/src/types/binding.ts
Adds JsFetchModuleOptions/JsFetchModuleResult to binding definitions; updates Compiler with fetchModule method; adjusts binding.cjs loading to use dynamic require; exposes new fetch/cache/externalize types.
Module Runner System - Foundations
packages/core/src/module-runner/constants.ts, packages/core/src/module-runner/createImportMeta.ts, packages/core/src/module-runner/diagnostics.ts, packages/core/src/module-runner/evaluatedModules.ts, packages/core/src/module-runner/evaluator.ts
Introduces SSR module export/import key constants; implements import.meta shim with URL normalization; adds ModuleRunnerDiagnosticsBus for event emission; defines EvaluatedModuleNode/EvaluatedModules for dependency tracking; implements host-specific module evaluators (Node/Worker/Bun/Deno) with runtime detection.
Module Runner System - Core
packages/core/src/module-runner/runner.ts, packages/core/src/module-runner/types.ts, packages/core/src/module-runner/serverInvoke.ts, packages/core/src/module-runner/serverTransport.ts, packages/core/src/module-runner/sourceMapInterceptor.ts, packages/core/src/module-runner/index.ts
Implements FarmModuleRunner orchestrating module fetching, evaluation, caching, and HMR; defines comprehensive type system for fetch results, external resolution, policies (external/nonJs/cache), diagnostics, and transport; adds server-side invocation handlers for module resolution and resource fetching; creates transport adapters for invoke/HMR communication; implements source map interception for stack trace remapping.
Server Integration
packages/core/src/server/index.ts, packages/core/src/server/ws.ts
Extends Server with moduleRunnerStamp, moduleRunnerInvokeHandlers, and moduleRunners collection; adds createModuleRunner and invokeModuleRunner methods; initializes module runner hooks for farm:invoke WebSocket communication; integrates module runner lifecycle with server create/restart/shutdown; updates WsServer listener signatures to accept variadic arguments.
Core Package Exports
packages/core/src/index.ts, packages/core/package.json
Re-exports module-runner public API; adds export entry for module-runner/serverInvoke.
SSR Package - Infrastructure
packages/ssr/package.json, packages/ssr/tsconfig.json, packages/ssr/tsconfig.build.json
Establishes new @farmfe/ssr package with TypeScript build tooling and workspace dependency on @farmfe/core.
SSR Package - Configuration & Utilities
packages/ssr/src/config-resolver.ts, packages/ssr/src/errors.ts, packages/ssr/src/manifest.ts, packages/ssr/src/runtime-hooks.ts, packages/ssr/src/runtime-types.ts
Implements SsrConfig type with client/server overrides; defines SsrError/SsrErrorCode for error handling; adds manifest parsing and asset injection utilities; defines runtime hooks for lifecycle callbacks (compile/invalidate/render/asset); exports runtime metadata types (command, assets, meta).
SSR Package - Runtime
packages/ssr/src/runtime.ts, packages/ssr/src/dev-server.ts, packages/ssr/src/build-preview.ts, packages/ssr/src/command.ts, packages/ssr/src/server.ts, packages/ssr/src/index.ts
Implements createSsrRuntime as unified entry point; full dev server with Farm watcher integration, HMR, module runner, and template rendering; implements build/preview with manifest generation and asset injection; adds SSR command handler for CLI integration; consolidates exports via index entrypoint.
SSR Package - Tests
packages/ssr/test/*.spec.ts
Comprehensive test coverage for build/preview, dev server, command routing, manifest utilities, and SSR toolkit example configuration.
Middleware Example
examples/middleware-mode/*
New Express-based middleware host demonstrating FarmFed server and module runner integration with HTTP endpoints for module execution and health checks.
SSR Toolkit Example
examples/ssr-toolkit/*
Complete Vue 3 SSR example with dev/preview modes, template rendering (HTML/EJS), dynamic route handling, and middleware integration; includes configuration, port resolution, and runtime setup modules.
Module Runner Tests
packages/core/tests/module-runner*.spec.ts
Extensive test suite covering module runner convergence (deduplication, cache TTL, LRU, GC), HMR flows (update/prune/full-reload), exports handling (default/named/CJS interop), import.meta resolution, non-JS policies, custom resolvers, diagnostics integration, source maps, and performance benchmarking.
Other
packages/core/src/plugin/js/vite-plugin-adapter.ts, packages/core/src/plugin/js/vite-plugin-adapter.spec.ts, crates/plugin_library/src/formats/umd.rs, vitest.config.ts
Updates Vite adapter to return UpdateType tuples instead of plain IDs; fixes documentation fence syntax; enables colored test output.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client/Importer
    participant Runner as FarmModuleRunner
    participant Transport as ModuleRunnerTransport
    participant Evaluator as ModuleEvaluator
    participant Cache as EvaluatedModules

    Client->>Runner: import(url)
    activate Runner
    
    Runner->>Cache: getModuleById(url)
    Cache-->>Runner: module | undefined
    alt Module cached
        Runner-->>Client: return cached exports
    else Module not cached
        Runner->>Transport: invoke('fetchModule', [id, importer])
        activate Transport
        Transport-->>Runner: FetchResult
        deactivate Transport
        
        alt Cached fetch result
            Runner-->>Client: return cached
        else External fetch result
            Runner->>Evaluator: runExternalModule(file, type)
            Evaluator-->>Runner: module value
            Runner-->>Client: return value
        else Inlined fetch result
            Runner->>Cache: ensureModule(id, url)
            Cache-->>Runner: EvaluatedModuleNode
            
            Runner->>Evaluator: runInlinedModule(context, code, module)
            activate Evaluator
            Evaluator->>Evaluator: evaluate code with __farm_ssr_*
            Evaluator-->>Runner: void (exports in context)
            deactivate Evaluator
            
            Runner->>Cache: cache exports
            Runner-->>Client: return exports
        end
    end
    
    deactivate Runner
Loading
sequenceDiagram
    participant User as User
    participant CLI as CLI
    participant ConfigResolver as Config Resolver
    participant DevServer as SSR Dev Server
    participant FarmServer as Farm Server
    participant ModuleRunner as Module Runner
    participant Template as Template Engine

    User->>CLI: farm ssr dev
    activate CLI
    
    CLI->>ConfigResolver: resolveSsrRunOptions(options)
    ConfigResolver-->>CLI: SsrRunOptions (client/server config)
    
    CLI->>DevServer: createSsrDevServer(options)
    activate DevServer
    
    DevServer->>FarmServer: createFarmServer(config)
    activate FarmServer
    FarmServer-->>DevServer: farm server instance
    deactivate FarmServer
    
    DevServer->>ModuleRunner: createModuleRunner()
    activate ModuleRunner
    ModuleRunner-->>DevServer: module runner instance
    deactivate ModuleRunner
    
    DevServer-->>CLI: dev server with render/listen
    deactivate DevServer
    
    CLI->>DevServer: listen(port)
    activate DevServer
    Note over DevServer: Server listening
    deactivate DevServer
    
    User->>DevServer: GET /
    activate DevServer
    
    DevServer->>Template: loadTemplate(options.ssr.template)
    Template-->>DevServer: template content
    
    DevServer->>ModuleRunner: import(ssr.entry)
    activate ModuleRunner
    ModuleRunner-->>DevServer: ssr module
    deactivate ModuleRunner
    
    DevServer->>DevServer: ssr.render(url)
    Note over DevServer: Render app HTML
    
    DevServer->>Template: injectAppHtml(template, html)
    Template-->>DevServer: final HTML
    
    DevServer-->>User: 200 text/html
    deactivate DevServer
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested reviewers

  • ErKeLost
  • wre232114
  • shulandmimi

Poem

🐰 Hop hop, the modules now can run!
Server-side eval, oh what fun!
From fetch to cache, from template to render,
SSR flows through every sender!
Update paths scrubbed clean and bright,
Module graphs guarded—no more panics in sight!

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ssr-toolkit

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (21)
examples/ssr-toolkit/README.md-16-16 (1)

16-16: ⚠️ Potential issue | 🟡 Minor

Link the known compilation.minify=false issue to its tracker entry.

The note documents a known Farm core bug but provides no reference, making it hard for users (or future contributors fixing it) to track it.

📝 Suggested revision
-当前 example 为保证 preview 懒加载路由稳定,client 构建使用了 `compilation.minify=false`(已记录为 Farm 核心已知问题,后续可在核心修复后恢复)。
+当前 example 为保证 preview 懒加载路由稳定,client 构建使用了 `compilation.minify=false`(已记录为 Farm 核心已知问题,见 issue `#XXXX`,后续可在核心修复后恢复)。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/ssr-toolkit/README.md` at line 16, Update the README note that
mentions compilation.minify=false to include a direct link to the Farm core bug
tracker entry (issue ID/URL) and a one-line summary of the bug; locate the text
containing "compilation.minify=false" in examples/ssr-toolkit/README.md and
append the tracker URL and issue number (or repo/issue reference) so future
readers and contributors can quickly find the canonical bug report.
crates/node/src/lib.rs-965-971 (1)

965-971: ⚠️ Potential issue | 🟡 Minor

_options parameter is entirely unused — cached and startOffset are silently ignored.

The _options parameter (prefixed with _ to suppress warnings) is never consumed. Callers passing { cached: true } or { startOffset: N } will get no behavioral difference. If caching opt-in or offset-based source maps are part of the planned API contract, this should at minimum log a warning or be documented as not-yet-implemented.

Would you like me to open an issue to track implementing the cached and startOffset option handling?

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/node/src/lib.rs` around lines 965 - 971, The fetch_module method
currently ignores its _options parameter so callers' cached and startOffset
flags have no effect; update fetch_module to parse the JsFetchModuleOptions
(remove the leading underscore from the parameter name to use it) and handle
cached by consulting/returning from whatever module cache logic is used in this
crate, and handle startOffset by adjusting the returned source or source-map
offsets (or applying the offset when creating JsFetchModuleResult); if full
support isn't yet implemented, at minimum emit a debug/warn log inside
fetch_module when cached or startOffset are provided to make the limitation
visible to callers and consider creating an issue to track full implementation.
packages/core/src/module-runner/createImportMeta.ts-93-105 (1)

93-105: ⚠️ Potential issue | 🟡 Minor

Same encodeURI concern in toAbsoluteDirectoryFileUrl.

Same issue as toFileUrl# and ? in directory paths will produce broken file URLs. If you address it in toFileUrl, apply the same fix here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/module-runner/createImportMeta.ts` around lines 93 - 105,
The toAbsoluteDirectoryFileUrl function incorrectly uses encodeURI which doesn't
escape '#' and '?' making file URLs break; update to use the same fix applied to
toFileUrl: build the file URL by encoding each path segment (e.g., split
normalized path by '/', encodeURIComponent each segment) and then rejoin with
'/' and append the trailing slash, or construct the URL via the URL constructor
with properly encoded segments, ensuring drive-letter and absolute-path branches
(the branches in toAbsoluteDirectoryFileUrl) produce correctly encoded file://
URLs; mirror the exact approach you implemented in toFileUrl so both functions
behave consistently.
packages/core/src/module-runner/serverTransport.ts-48-53 (1)

48-53: ⚠️ Potential issue | 🟡 Minor

Missing guard for unknown invoke handler name.

If deps.invokeHandlers[name] is undefined, the cast to a function and the subsequent call will throw an unhelpful TypeError: handler is not a function. Consider adding a descriptive check.

Proposed fix
     async invoke(name, data) {
       const handler = deps.invokeHandlers[name] as (
         ...args: unknown[]
       ) => Promise<unknown>;
+      if (typeof handler !== 'function') {
+        throw new Error(
+          `[farm module runner] Invoke handler "${String(name)}" is not registered.`
+        );
+      }
       return (await handler(...(data as unknown[]))) as never;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/module-runner/serverTransport.ts` around lines 48 - 53, The
invoke method casts and calls deps.invokeHandlers[name] without checking
existence, which yields an unhelpful TypeError when the handler is missing;
update the invoke function to verify that deps.invokeHandlers[name] exists and
is a function before calling it (e.g., check typeof handler === "function"), and
if not, throw a descriptive error (for example "Unknown invoke handler: <name>"
or similar) so callers get a clear message; reference the invoke method and
deps.invokeHandlers to locate where to add this guard and error.
packages/core/src/module-runner/runner.ts-64-67 (1)

64-67: ⚠️ Potential issue | 🟡 Minor

import() does not check this.closed, allowing use-after-close.

After close() is called, the transport is disconnected and caches are cleared. Calling import() would attempt to invoke on a disconnected transport, producing confusing errors. Consider adding a guard.

Proposed fix
   async import<T = unknown>(url: string): Promise<T> {
+    if (this.closed) {
+      throw new Error(
+        '[farm module runner] Cannot import after runner has been closed.'
+      );
+    }
     const mod = await this.cachedModule(url);
     return (await this.cachedRequest(url, mod)) as T;
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/module-runner/runner.ts` around lines 64 - 67, The
import<T>(url: string) method calls this.cachedModule and this.cachedRequest
even after close() has been called, which can trigger operations on a
disconnected transport; add a guard at the start of import (check this.closed)
and throw a clear error (or return a rejected Promise) if closed is true,
preventing use-after-close; ensure the same pattern is used for related entry
points that call cachedModule/cachedRequest so callers get a consistent "runner
is closed" error instead of transport errors.
packages/core/src/module-runner/runner.ts-55-61 (1)

55-61: ⚠️ Potential issue | 🟡 Minor

Unhandled rejection risk if transport.connect is asynchronous and fails.

void discards the return value. If a custom transport's connect() returns a rejecting Promise, the rejection is never caught, potentially crashing the process with an unhandled promise rejection.

Proposed fix
-      void this.options.transport.connect({
+      Promise.resolve(this.options.transport.connect({
         onMessage: (payload) => this.handleHotPayload(payload),
         onDisconnection: () => {
           // No-op for now. Keep room for future reconnect strategy.
         }
-      });
+      })).catch((err) => {
+        console.warn(
+          `[farm module runner] HMR transport connect failed: ${err}`
+        );
+      });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/module-runner/runner.ts` around lines 55 - 61, The code
currently calls this.options.transport.connect(...) with a leading void which
discards a possibly rejecting Promise; if connect() is async and rejects it will
become an unhandled rejection. Fix by handling the returned Promise from
this.options.transport.connect: either await it inside an async function (e.g.,
make the enclosing method async and use await
this.options.transport.connect(...)) or append a .catch(...) to the call to
log/handle errors (use your module runner's logger or a provided error handler)
so rejections are observed; reference this.options.transport.connect and ensure
any cleanup/reconnect logic (onDisconnection/handleHotPayload) remains intact.
packages/core/src/server/index.ts-484-500 (1)

484-500: ⚠️ Potential issue | 🟡 Minor

Potential double-compilation race in createModuleRunner if called concurrently.

Two concurrent createModuleRunner() calls can both observe !this.compiler === true before either completes #startCompile(), resulting in two compilers being created (the second overwriting the first). While unlikely in current usage, this could cause subtle bugs if the API is used more broadly.

A simple guard would prevent re-entrant compilation:

Proposed fix — guard against concurrent compilation
+  private _startCompilePromise: Promise<void> | null = null;
+
   async createModuleRunner(
     options: Omit<FarmModuleRunnerOptions, 'transport'> = {}
   ): Promise<FarmModuleRunner> {
     if (!this.compiler) {
-      await this.#startCompile();
-      this.watcher?.watchExtraFiles?.();
+      if (!this._startCompilePromise) {
+        this._startCompilePromise = this.#startCompile().then(() => {
+          this.watcher?.watchExtraFiles?.();
+        });
+      }
+      await this._startCompilePromise;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/server/index.ts` around lines 484 - 500, createModuleRunner
can race when called concurrently because both callers may see !this.compiler
and call `#startCompile`; add a single-entry guard (e.g., a private Promise/flag
like this._compilePromise or this._isCompiling) so the first caller sets the
guard then awaits this.#startCompile(), and subsequent callers await the same
promise instead of calling `#startCompile` again; ensure the
watcher?.watchExtraFiles() invocation happens after the compile completes and
clear the guard when done.
packages/core/src/module-runner/createImportMeta.ts-31-67 (1)

31-67: ⚠️ Potential issue | 🟡 Minor

Using encodeURI leaves # and ? unencoded, breaking file URLs with these characters in their paths.

encodeURI intentionally leaves # and ? unencoded since they're URL syntax delimiters. A file path like /home/user/my#project/file.ts would produce file:///home/user/my#project/file.ts, where everything after # is interpreted as a URL fragment, corrupting the file path.

However, importing Node's pathToFileURL would break the runtime-agnostic design of this file—it defensively checks for globalThis.process and is used in non-Node environments. Instead, encode path segments individually using encodeURIComponent, which properly escapes #, ?, and other reserved characters:

 function toFileUrl(modulePath: string): string {
   const normalized = modulePath.replace(/\\/g, '/');

   if (/^[a-zA-Z]:\//.test(normalized)) {
-    return `file:///${encodeURI(normalized)}`;
+    return `file:///${normalized.split('/').map(encodeURIComponent).join('/')}`;
   }

   if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(modulePath)) {
     return modulePath;
   }

   if (normalized.startsWith('//')) {
-    return `file:${encodeURI(normalized)}`;
+    return `file:${normalized.split('/').map(encodeURIComponent).join('/')}`;
   }

   if (normalized.startsWith('/')) {
-    return `file://${encodeURI(normalized)}`;
+    return `file://${normalized.split('/').map(encodeURIComponent).join('/')}`;
   }

Apply the same fix to toAbsoluteDirectoryFileUrl (lines 97, 101).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/module-runner/createImportMeta.ts` around lines 31 - 67,
The toFileUrl function currently uses encodeURI(normalized), which leaves
characters like '#' and '?' unescaped and breaks file URLs; fix by encoding each
path segment with encodeURIComponent instead of encodeURI (split the normalized
path on '/', encodeURIComponent for each non-empty segment, then rejoin,
preserving leading '//' or '/' and drive-letter windows patterns), and replace
all other uses of encodeURI in the file—specifically update
toAbsoluteDirectoryFileUrl similarly (encode each path segment with
encodeURIComponent when building the file:// URL) so reserved characters are
correctly escaped while keeping the existing runtime-agnostic checks and
behavior.
examples/ssr-toolkit/tsconfig.json-8-8 (2)

8-8: ⚠️ Potential issue | 🟡 Minor

"jsx": "react" is wrong for a Vue 3 project — use "preserve" with "jsxImportSource": "vue".

If you intend to use TSX with Vue, set compilerOptions.jsx to "preserve", and set compilerOptions.jsxImportSource to "vue". The Vue official base tsconfig (vuejs/tsconfig) also marks "jsx": "preserve" and "jsxImportSource": "vue" as "Required in Vue projects". Using "react" makes TypeScript emit React.createElement calls, which produces broken output for any .tsx file in src/.

🔧 Proposed fix
-    "jsx": "react"
+    "jsx": "preserve",
+    "jsxImportSource": "vue"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/ssr-toolkit/tsconfig.json` at line 8, The tsconfig currently sets
compilerOptions.jsx to "react", which is incorrect for a Vue 3 project; change
compilerOptions.jsx to "preserve" and add compilerOptions.jsxImportSource set to
"vue" so TypeScript emits Vue-compatible JSX/TSX output. Update the JSON keys
compilerOptions.jsx and compilerOptions.jsxImportSource (and ensure any TSX
usage relies on these settings) to match the Vue recommended base tsconfig.

8-8: ⚠️ Potential issue | 🟡 Minor

Change "jsx": "react" to "preserve" for Vue 3 compatibility.

The "jsx": "react" setting makes TypeScript emit React.createElement calls for any .tsx / JSX syntax. Since this is a Vue 3 project using @vitejs/plugin-vue, the correct value is "preserve", which leaves JSX untouched so that Vite's Vue JSX plugin can handle the transform. With "react", any TSX files will produce broken output at runtime.

🔧 Proposed fix
-    "jsx": "react"
+    "jsx": "preserve"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/ssr-toolkit/tsconfig.json` at line 8, The tsconfig "jsx" option is
set to "react" which causes TypeScript to emit React.createElement and breaks
Vue 3/Vite handling; update the "jsx" compiler option in tsconfig.json from
"react" to "preserve" so JSX/TSX is left untouched for `@vitejs/plugin-vue/vue`
JSX transform (look for the "jsx" entry in tsconfig.json and change its value).
packages/cli/src/utils.ts-58-63 (2)

58-63: ⚠️ Potential issue | 🟡 Minor

Inner catch silently swallows the fallback-path error.

The inner catch {} has no binding, so err.stack on line 60 reports the primary import failure (@farmfe/ssr not found), not the fallback path failure. If the fallback itself fails for a different reason (e.g., the monorepo dist/ hasn't been built yet), that root cause is lost. Consider capturing both errors:

🔧 Suggested improvement
-    } catch {
+    } catch (fallbackErr) {
       logger.error(
-        `Cannot find `@farmfe/ssr` module, Did you successfully install: \n${err.stack},`,
+        `Cannot find `@farmfe/ssr` module. Primary error: ${err.stack}\nFallback error: ${(fallbackErr as Error).stack}`,
         { exit: true }
       );
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/utils.ts` around lines 58 - 63, The inner catch currently
swallows the fallback error and still logs only the original import error
(err.stack); change the empty inner catch to capture the fallback exception
(e.g., catch (fallbackErr)) and update the logger.error call (logger.error(...))
to include both the original import error and the fallback error (include
err.stack and fallbackErr.stack or their messages) so you surface both failure
causes when attempting to require '@farmfe/ssr' and the fallback path.

58-63: ⚠️ Potential issue | 🟡 Minor

Inner catch swallows the fallback-path error; only the primary error is surfaced.

The inner catch {} has no binding, so err.stack on line 60 always reports the primary import failure (@farmfe/ssr not found). If the fallback itself fails for a distinct reason (e.g., packages/ssr/dist/ hasn't been built), that root cause is silently discarded, making debugging very hard.

🔧 Suggested fix
-    } catch {
+    } catch (fallbackErr) {
       logger.error(
-        `Cannot find `@farmfe/ssr` module, Did you successfully install: \n${err.stack},`,
+        `Cannot find `@farmfe/ssr` module.\nPrimary error: ${err.stack}\nFallback error: ${(fallbackErr as Error).stack}`,
         { exit: true }
       );
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/utils.ts` around lines 58 - 63, The inner empty catch is
losing the real fallback error; update the inner catch to capture the fallback
exception (e.g., catch (fallbackErr)) and include its details when calling
logger.error so both the primary import error (err) and the fallback error
(fallbackErr) are surfaced; specifically modify the try/catch around the
`@farmfe/ssr` import so the logger.error call references err.stack and
fallbackErr.stack (or otherwise concatenates both error messages) instead of
relying only on err.stack, ensuring you use distinct variable names to avoid
shadowing.
examples/ssr-toolkit/package.json-7-7 (1)

7-7: ⚠️ Potential issue | 🟡 Minor

@vitejs/plugin-vue belongs in devDependencies.

It's a build-time transform plugin (used only in the Farm client config), not a runtime dependency. Only vue and vue-router are genuinely needed at runtime.

🔧 Proposed fix
   "dependencies": {
-    "@vitejs/plugin-vue": "^6.0.4",
     "vue": "^3.3.12",
     "vue-router": "^4.2.1"
   },
   "devDependencies": {
+    "@vitejs/plugin-vue": "^6.0.4",
     "@farmfe/core": "workspace:*",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/ssr-toolkit/package.json` at line 7, Move "@vitejs/plugin-vue" out
of the package.json "dependencies" section and add it under "devDependencies"
instead; update the package.json so only runtime packages (e.g., "vue",
"vue-router") remain in "dependencies" and build-time plugins like
"@vitejs/plugin-vue" are declared in "devDependencies" to reflect that the
plugin is used only in the Farm client build/config.
examples/ssr-toolkit/src/styles/app.less-1-4 (1)

1-4: ⚠️ Potential issue | 🟡 Minor

Add customSyntax override for LESS files to prevent false Stylelint errors.

The project's Stylelint config extends stylelint-config-standard-scss globally, which applies SCSS parsing rules to LESS files. This causes the LESS variable syntax (@var: value) to be flagged as unknown at-rules. The fix is to add a customSyntax override for *.less files in the Stylelint config:

🛠️ Proposed Stylelint config override
 // .stylelintrc.json
 {
   "extends": [
     "stylelint-config-standard-scss"
   ],
+  "overrides": [
+    {
+      "files": ["**/*.less"],
+      "customSyntax": "postcss-less"
+    }
+  ],
   "rules": {
     ...
   }
 }

Note: Ensure postcss-less is installed as a dev dependency for this to work.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/ssr-toolkit/src/styles/app.less` around lines 1 - 4, Add a Stylelint
override that sets customSyntax: "postcss-less" for "*.less" files (so LESS
variable syntax like `@var`: value isn't parsed as SCSS at-rules); update the
Stylelint config (e.g., stylelint.config.js or .stylelintrc) to include an
overrides entry targeting "*.less" with customSyntax set to "postcss-less", and
install postcss-less as a devDependency so the parser is available.
packages/cli/src/ssr.ts-53-66 (1)

53-66: ⚠️ Potential issue | 🟡 Minor

parsePort accepts fractional port numbers.

parsePort validates that the value is a finite positive number but doesn't reject fractional values like 3.5 or 80.1, which are not valid port numbers.

Proposed fix
   const parsed = Number(rawPort);
-  if (!Number.isFinite(parsed) || parsed <= 0) {
+  if (!Number.isFinite(parsed) || parsed <= 0 || !Number.isInteger(parsed)) {
     throw new Error(
-      `[farm ssr] invalid --port value "${String(rawPort)}", expected a positive number.`
+      `[farm ssr] invalid --port value "${String(rawPort)}", expected a positive integer.`
     );
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/ssr.ts` around lines 53 - 66, parsePort currently accepts
fractional port numbers because it only checks Number.isFinite and >0; update
parsePort to also require an integer (e.g., using Number.isInteger(parsed)) so
values like 3.5 or 80.1 are rejected and still throw the same error message.
Locate the parsePort function and add an integer check alongside the existing
finite/positive checks to ensure only whole-number ports are accepted.
packages/cli/src/index.ts-239-253 (1)

239-253: ⚠️ Potential issue | 🟡 Minor

Loose typing at the resolveSsr()runSsrCommand boundary creates undetected shape mismatches.

resolveSsr() returns runSsrCommand typed as (options: Record<string, unknown>) => Promise<unknown>, while the actual function expects SsrRunCommandOptions. The SsrRunOptions produced by the CLI differs in shape from what the SSR package expects:

  • CLI's server field is required in all commands, but SsrDevServerOptions.server is optional
  • CLI's nested ssr.template is optional, but SsrRenderOptions.template is required
  • CLI's ssr object lacks fields like shouldHandle, render, and onError that SsrRenderOptions defines

These incompatibilities won't be caught at compile time due to the loose boundary typing. Consider either:

  1. Using proper return type for resolveSsr() to enable TypeScript checking, or
  2. Adding runtime validation to catch shape mismatches early
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/index.ts` around lines 239 - 253, The createSsrAction
wrapper is calling runSsrCommand from resolveSsr() which is currently typed
loosely; tighten the boundary by updating resolveSsr() to return a properly
typed signature (e.g., runSsrCommand: (opts: SsrRunCommandOptions) =>
Promise<void>) or cast/validate resolved value before use, and adapt
resolveSsrRunOptions output to match SsrRunCommandOptions (ensure server
optionality, required ssr.render/template/shouldHandle/onError fields are
present or supply defaults). Specifically, change the resolveSsr() return type
or add a runtime validation/mapping step in createSsrAction (between
resolveSsrRunOptions and runSsrCommand) to transform/validate the CLI
SsrRunOptions into the expected SsrRunCommandOptions shape so compile-time or
early runtime errors surface.
examples/middleware-mode/src/main.ts-16-22 (1)

16-22: ⚠️ Potential issue | 🟡 Minor

Async click handler swallows errors silently.

If fetch throws (e.g., network error) or response.json() rejects (non-JSON body), the rejection is unhandled: the #runner-result element never updates and the browser console receives an unhandled rejection warning.

Note: the ast-grep XSS warning for innerHTML on lines 3–10 is a false positive — the content is a fully static template literal with no dynamic data.

🔧 Suggested fix
  button?.addEventListener('click', async () => {
-   const response = await fetch('/api/runner');
-   const payload = await response.json();
-   if (result) {
-     result.textContent = JSON.stringify(payload, null, 2);
-   }
+   try {
+     const response = await fetch('/api/runner');
+     const payload = await response.json();
+     if (result) {
+       result.textContent = JSON.stringify(payload, null, 2);
+     }
+   } catch (err) {
+     if (result) {
+       result.textContent = `Error: ${err instanceof Error ? err.message : String(err)}`;
+     }
+   }
  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/middleware-mode/src/main.ts` around lines 16 - 22, The click handler
on button (button?.addEventListener('click', async () => { ... })) can throw
during fetch() or response.json(), leaving the `#runner-result` element unchanged
and causing unhandled rejections; wrap the async handler body in a try/catch,
await fetch('/api/runner') and response.json() inside the try, and in the catch
set result.textContent to a clear error message (and optionally process/log the
error via console.error) so failures are displayed instead of swallowed; ensure
you reference the same result variable used to update the DOM.
examples/middleware-mode/server.mjs-37-37 (1)

37-37: ⚠️ Potential issue | 🟡 Minor

Hardcoded HMR port 9801 in log may not match actual server config.

The HMR port is determined by farm.config.ts (and the { hmr: true } option passed to createModuleRunner), not hard-coded to 9801. This log line can silently mislead developers when the config uses a different port.

🔧 Suggested fix

Derive the port from the server config or omit the specific number:

-  console.log('hmr ws port: 9801');
+  // console.log(`hmr ws port: ${farmServer.config?.server?.hmr?.port ?? '<from config>'}`);

Or, if farmServer exposes the resolved HMR port after startup, log it there.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/middleware-mode/server.mjs` at line 37, Remove the hardcoded
console.log('hmr ws port: 9801') and instead log the actual HMR port from the
runtime config: obtain the HMR port from the farm config or from the server
instance (e.g., the createModuleRunner/farmServer result) and use that value in
the message (or log a generic "HMR websocket listening" message if the port
isn't exposed); update the console.log call in server.mjs accordingly to
reference the resolved port variable or omit the numeric port.
examples/ssr-toolkit/server/ports.mjs-35-39 (1)

35-39: ⚠️ Potential issue | 🟡 Minor

Error message references process.env directly instead of the explicitPort value.

Line 38 uses process.env[envName] to display the invalid value, but in test contexts the actual explicitPort comes from a custom env object (via parseOptionalNumber in runtime-config.mjs), not process.env. The error message would show undefined in tests rather than the offending value.

Consider using the explicitPort parameter directly:

🔧 Proposed fix
     if (!isPositiveInteger(explicitPort)) {
       throw new Error(
-        `[ssr-toolkit] invalid ${envName}="${process.env[envName]}", expected a positive integer.`
+        `[ssr-toolkit] invalid ${envName}="${explicitPort}", expected a positive integer.`
       );
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/ssr-toolkit/server/ports.mjs` around lines 35 - 39, The error
message in the port validation uses process.env[envName] which can be undefined
in test contexts where the port comes from
parseOptionalNumber/runtime-config.mjs; update the throw in the explicitPort !=
null branch to reference the explicitPort variable (and envName for context) so
the logged invalid value is the actual offending value (e.g., use explicitPort
in the message near isPositiveInteger, keeping envName for clarity).
examples/ssr-toolkit/server/template.mjs-26-32 (1)

26-32: ⚠️ Potential issue | 🟡 Minor

Lazy *? truncates at the first nested </div>, leaving a dangling close tag.

For templates where the root container has any nested <div> element (e.g., a loading skeleton or Farm's own documented placeholder pattern <div id="root"><div>app-html-to-replace</div></div>), the regex stops at the inner </div>, producing:

<!-- input -->
<div id="root"><div>spinner</div></div>

<!-- output (broken) -->
<div id="root"><!-- appHtml --></div></div>

A depth-counting approach fixes this reliably:

🐛 Proposed fix — depth-aware root container replacement
 export function injectAppHtmlIntoBuiltTemplate(template, appHtml) {
-  const rootContainerPattern = /<div\s+id=(?:"root"|'root'|root)[^>]*>[\s\S]*?<\/div>/;
-
-  if (!rootContainerPattern.test(template)) {
+  const openTagMatch = template.match(/<div\s+id=(?:"root"|'root'|root)[^>]*>/);
+
+  if (!openTagMatch) {
     throw new Error('[ssr-toolkit] failed to locate root container in built html template.');
   }
-
-  return template.replace(rootContainerPattern, `<div id="root">${appHtml}</div>`);
+
+  const start = openTagMatch.index;
+  const afterOpen = start + openTagMatch[0].length;
+  let depth = 1;
+  let pos = afterOpen;
+
+  while (pos < template.length && depth > 0) {
+    const nextOpen = template.indexOf('<div', pos);
+    const nextClose = template.indexOf('</div>', pos);
+    if (nextClose === -1) break;
+    if (nextOpen !== -1 && nextOpen < nextClose) {
+      depth++;
+      pos = nextOpen + 4;
+    } else {
+      depth--;
+      if (depth === 0) {
+        return (
+          template.slice(0, start) +
+          `<div id="root">${appHtml}</div>` +
+          template.slice(nextClose + 6)
+        );
+      }
+      pos = nextClose + 6;
+    }
+  }
+
+  throw new Error('[ssr-toolkit] failed to locate root container in built html template.');
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/ssr-toolkit/server/template.mjs` around lines 26 - 32, The current
rootContainerPattern regex (rootContainerPattern) uses a lazy quantifier and can
stop at the first nested </div>, producing dangling tags; instead parse the
template into a DOM and locate the element with id "root" (e.g., create a DOM
parser/JSDOM or use a lightweight HTML parser), replace that element's innerHTML
with appHtml, and serialize back to string rather than using template.replace
with rootContainerPattern; update the code around rootContainerPattern, the test
that throws the Error, and the return that calls template.replace so they
operate on the parsed DOM (find element by id "root", ensure it exists, set its
innerHTML to appHtml, then return the serialized document).
packages/ssr/src/index.ts-35-53 (1)

35-53: ⚠️ Potential issue | 🟡 Minor

Export SsrUpdateItem and SsrUpdateResult from the public API.

SsrDevCompilerLike (re-exported from ./dev-server.js) declares update(paths: SsrUpdateItem[]): Promise<SsrUpdateResult>. Implementers providing a custom server compiler must import both SsrUpdateItem and SsrUpdateResult to properly type this method, but neither type is currently exported from the public API surface. Add both to the re-export list in packages/ssr/src/index.ts.

♻️ Proposed addition
 export type {
   SsrDevCompilerLike,
   SsrDevFarmServerLike,
   SsrDevHostServerLike,
   SsrDevServer,
   SsrDevServerCompilerCreateResult,
   SsrDevServerFactories,
   SsrDevServerListenOptions,
   SsrDevServerOptions,
   SsrDevWatcherLike,
   SsrMiddleware,
   SsrMiddlewareServer,
   SsrNextMiddleware,
   SsrRenderContext,
   SsrRenderOptions,
+  SsrUpdateItem,
+  SsrUpdateResult,
   SsrTemplateLoadContext,
   SsrTemplateOptions,
   SsrTemplateRenderContext
 } from './dev-server.js';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ssr/src/index.ts` around lines 35 - 53, The public API is missing
SsrUpdateItem and SsrUpdateResult which are required by the
SsrDevCompilerLike.update signature; update the re-export block in
packages/ssr/src/index.ts to also export SsrUpdateItem and SsrUpdateResult from
'./dev-server.js' so implementers can import those types used by
SsrDevCompilerLike.update(paths: SsrUpdateItem[]): Promise<SsrUpdateResult>.

hmr: {
port: 9801
},
middlewares

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

middlewares is an undefined identifier — compile error / runtime ReferenceError.

The shorthand property syntax { middlewares } expands to { middlewares: middlewares }, but middlewares is never imported or declared in this file. TypeScript will reject it with Cannot find name 'middlewares', and at runtime it will throw a ReferenceError. Either import/declare the middleware array, or remove the line if middleware wiring lives elsewhere (e.g., in server.mjs).

🐛 Example fix
 import { defineConfig } from '@farmfe/core';
+import type { UserConfig } from '@farmfe/core';
+
+// Define or import your middleware array
+const middlewares: UserConfig['server']['middlewares'] = [
+  // ... your middleware entries
+];
 
 export default defineConfig({
   server: {
     middlewareMode: true,
     hmr: {
       port: 9801
-    },
-    middlewares
+    }
+    // middlewares wired via server.mjs, not here
   }
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/middleware-mode/farm.config.ts` at line 9, The file references an
undefined identifier middlewares via shorthand object property (the `{
middlewares }` line) causing a compile/runtime error; either import or declare
the middleware array (e.g., add an import or const middlewares = [...] in this
module) and use that symbol, or remove the middlewares property from the
exported config if middleware wiring is handled elsewhere (e.g., in server.mjs)
— update the `middlewares` reference in farm.config.ts accordingly.

⚠️ Potential issue | 🔴 Critical

middlewares is undefined — compile error / runtime ReferenceError.

The shorthand property middlewares on line 9 expands to middlewares: middlewares, but middlewares is never imported or declared in this file. TypeScript will refuse to compile it (Cannot find name 'middlewares'), and the runtime will throw a ReferenceError.

Either import/define the middleware array before the config object, or remove the line if middleware is handled elsewhere:

🐛 Example fix (remove the dangling reference)
 import { defineConfig } from '@farmfe/core';
+import { middlewares } from './middlewares.js'; // adjust import to actual source
 
 export default defineConfig({
   server: {
     middlewareMode: true,
     hmr: {
       port: 9801
-    },
-    middlewares
+    }
   }
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/middleware-mode/farm.config.ts` at line 9, The config object
contains a dangling shorthand property "middlewares" that references an
undeclared identifier and causes a compile/runtime error; either remove the
"middlewares" property from the exported config or import/declare the middleware
array before the config (e.g., define const middlewares: Middleware[] = [...] or
import { middlewares } from '...') and ensure the exported config uses that
declared symbol (the shorthand "middlewares" within the config object or an
explicit "middlewares: middlewares" assignment).

Comment on lines +22 to +24
```bash
scripts/pnpm-node22.sh --filter @farmfe/cli build
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the referenced wrapper script and server module files exist.

echo "=== Checking scripts/pnpm-node22.sh ==="
fd -t f "pnpm-node22.sh" scripts/

echo ""
echo "=== Checking if file is executable ==="
if [ -f "scripts/pnpm-node22.sh" ]; then
  ls -la scripts/pnpm-node22.sh
else
  echo "File not found"
fi

echo ""
echo "=== Checking server/ module files referenced in README ==="
fd -t f -e mjs . examples/ssr-toolkit/server/

Repository: farm-fe/farm

Length of output: 380


The script scripts/pnpm-node22.sh does not exist in the repository. The bash command shown in the README references a non-existent file, which will cause the example to fail when users attempt to follow it. Either create the missing script or update the command to use the correct path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/ssr-toolkit/README.md` around lines 22 - 24, The README references a
non-existent script scripts/pnpm-node22.sh in the example command; update the
README to point to the correct existing script or add the missing script. Locate
the example command line containing "scripts/pnpm-node22.sh --filter `@farmfe/cli`
build" and either replace scripts/pnpm-node22.sh with the proper script
path/name used in the repo (or a generic alternative like pnpm) or create the
scripts/pnpm-node22.sh wrapper script that sets Node 22 and runs the given pnpm
command so the example works as written.

Comment on lines +176 to +198
fetchModule(
id: string,
importer?: string,
options?: JsFetchModuleOptions
): JsFetchModuleResult | null {
const bindingCompiler = this._bindingCompiler as BindingCompiler & {
fetchModule?: (
id: string,
importer?: string | null,
options?: JsFetchModuleOptions | null
) => JsFetchModuleResult | null;
};

if (typeof bindingCompiler.fetchModule !== 'function') {
return null;
}

return (bindingCompiler.fetchModule(
id,
importer ?? null,
options ?? null
) ?? null) as JsFetchModuleResult | null;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, find and examine the binding definition files
fd -type f -name "binding.d.ts" -o -name "binding.js" | head -20

Repository: farm-fe/farm

Length of output: 226


🏁 Script executed:

# Look for the binding.d.ts file structure
find . -name "binding.d.ts" -type f

Repository: farm-fe/farm

Length of output: 92


🏁 Script executed:

# Examine the types in src/types/binding.ts and binding.d.ts
fd -type f "binding.ts" "binding.d.ts" | xargs wc -l

Repository: farm-fe/farm

Length of output: 245


🏁 Script executed:

# Read the binding.d.ts file to check JsFetchModuleResult definition
cat ./packages/core/binding/binding.d.ts

Repository: farm-fe/farm

Length of output: 3796


🏁 Script executed:

# Find and read src/types/binding.ts
find . -path "*/src/types/binding.ts" -type f | head -1 | xargs cat

Repository: farm-fe/farm

Length of output: 18152


🏁 Script executed:

# Search for fetchModule usage (corrected rg command without -type flag)
rg -n 'fetchModule' --glob '!binding.d.ts' --glob '!node_modules' -A 2 -B 2

Repository: farm-fe/farm

Length of output: 50368


🏁 Script executed:

# Find the isFetchResult function definition
rg -n "isFetchResult" packages/core/src/module-runner/serverInvoke.ts -A 10 -B 2

Repository: farm-fe/farm

Length of output: 795


🏁 Script executed:

# Look at the validation logic in serverInvoke.ts around lines 484-488
sed -n '480,495p' packages/core/src/module-runner/serverInvoke.ts

Repository: farm-fe/farm

Length of output: 455


🏁 Script executed:

# Check if there's any type validation or normalization happening
rg -n "function isFetchResult|const isFetchResult" packages/core/src -A 15

Repository: farm-fe/farm

Length of output: 1265


🏁 Script executed:

# Get the complete isFetchResult function
sed -n '173,220p' packages/core/src/module-runner/serverInvoke.ts

Repository: farm-fe/farm

Length of output: 1236


🏁 Script executed:

# Also check what FetchResult type is
rg -n "type FetchResult|interface FetchResult" packages/core/src/module-runner/serverInvoke.ts -A 20

Repository: farm-fe/farm

Length of output: 38


🏁 Script executed:

# Find FetchResult type definition
rg -n "type FetchResult" packages/core/src/module-runner/ -B 2 -A 10

Repository: farm-fe/farm

Length of output: 877


🏁 Script executed:

# Also look at the imports in serverInvoke.ts
head -50 packages/core/src/module-runner/serverInvoke.ts | grep -A 5 -B 5 "import"

Repository: farm-fe/farm

Length of output: 504


🏁 Script executed:

# Let me check the exact types in module-runner/types.ts to compare with src/types/binding.ts
sed -n '60,92p' packages/core/src/module-runner/types.ts

Repository: farm-fe/farm

Length of output: 837


🏁 Script executed:

# Check if JsFetchModuleResult is actually used or if it's converted to FetchResult
rg -n "JsFetchModuleResult" packages/core/src/module-runner/ -B 2 -A 2

Repository: farm-fe/farm

Length of output: 38


🏁 Script executed:

# Search across the entire codebase for JsFetchModuleResult
rg -n "JsFetchModuleResult" packages/core/src -B 2 -A 2

Repository: farm-fe/farm

Length of output: 1665


🏁 Script executed:

# Look at the compiler/index.ts imports to see what's being imported
head -30 packages/core/src/compiler/index.ts

Repository: farm-fe/farm

Length of output: 939


🏁 Script executed:

# Check the comment at the top of binding.d.ts
head -5 packages/core/binding/binding.d.ts

Repository: farm-fe/farm

Length of output: 200


🏁 Script executed:

# Look at how the Rust binding actually implements fetchModule
fd -type f "*.rs" packages/core | xargs rg -l "fetchModule" | head -5

Repository: farm-fe/farm

Length of output: 447


🏁 Script executed:

# Check how the actual binding result is constructed - look at test mocks vs actual usage
sed -n '3282,3320p' packages/core/tests/module-runner.spec.ts

Repository: farm-fe/farm

Length of output: 1257


🏁 Script executed:

# Let me check what happens when fetchModule is called - trace the actual return
rg -n "compiler.fetchModule" packages/core/src/module-runner/serverInvoke.ts -B 5 -A 10

Repository: farm-fe/farm

Length of output: 470


🏁 Script executed:

# Check if there's any transformation/normalization of the result
sed -n '411,445p' packages/core/src/module-runner/serverInvoke.ts

Repository: farm-fe/farm

Length of output: 892


🏁 Script executed:

# Look at normalizeInlinedFetchResult and how results are actually used after validation
sed -n '216,260p' packages/core/src/module-runner/serverInvoke.ts

Repository: farm-fe/farm

Length of output: 1142


🏁 Script executed:

# Check the full resolveFetchModule function to see what happens after isFetchResult validation
sed -n '438,510p' packages/core/src/module-runner/serverInvoke.ts

Repository: farm-fe/farm

Length of output: 1887


🏁 Script executed:

# Let me verify the return type signature in binding.d.ts for fetchModule method
sed -n '20,25p' packages/core/binding/binding.d.ts

Repository: farm-fe/farm

Length of output: 378


The type mismatch is real but partially mitigated by runtime validation.

The Rust binding declares JsFetchModuleResult as a flat interface with all optional fields (binding.d.ts lines 52-63), but src/types/binding.ts shadows this with a discriminated union requiring specific fields per variant (lines 44-47: JsCachedFetchResult | JsExternalizedFetchResult | JsInlinedFetchResult). The cast on line 197 silently bridges these incompatible shapes.

However, the impact is partially mitigated: serverInvoke.ts validates results against the union structure via isFetchResult() (lines 173-213), which would reject malformed shapes. This guards against the type mismatch at runtime but doesn't resolve the underlying type unsoundness.

The unsafe cast remains problematic because:

  • It bypasses TypeScript type safety for this return path
  • Future refactoring could break if validation is removed or changed
  • It creates unnecessary complexity in the type system

Either align the TS union variants to match the binding's flat-optional shape, or make the binding's type declaration explicit about the discriminated union it actually returns.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/compiler/index.ts` around lines 176 - 198, The fetchModule
method is unsafely casting the Rust binding's flat-optional JsFetchModuleResult
into a stricter discriminated union (defined in src/types/binding.ts), bypassing
TypeScript checks; instead, update types so they match: either change the
binding declaration of JsFetchModuleResult to express the discriminated union
shape the runtime actually returns, or relax the TS union to accept the
flat-optional form returned by BindingCompiler.fetchModule, then remove the
unsafe cast in fetchModule and rely on the existing runtime guard isFetchResult
(from serverInvoke.ts) to validate results; locate and adjust the types around
BindingCompiler, fetchModule, and JsFetchModuleResult so the compile-time types
align with runtime validation.

Comment on lines +512 to +516
export class BunModulesEvaluator extends BaseAsyncFunctionEvaluator {
constructor(resolveExternalModule?: ExternalModuleResolver) {
super('bun', false, resolveExternalModule);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Bun CommonJS require support in ESM contexts native

💡 Result:

Bun’s runtime has native CommonJS + ESM interop, and (unlike Node.js ESM) it lets you call require() from an ES module.

  • In Bun, require can be used by both ES modules and CommonJS modules. When you require():

    • an ES module, Bun returns the module namespace object (similar to import * as).
    • a CommonJS module, Bun returns module.exports.
      [1]
  • Bun also explicitly documents that you can require() (or import) CommonJS from ESM as a supported pattern. [3]

If you’re targeting Node.js compatibility, note this is a Bun runtime feature; Node’s ESM generally doesn’t provide a global require() the same way. [1]


🏁 Script executed:

cd /tmp && cat > find_bun_evaluator.sh << 'EOF'
#!/bin/bash
# Find the BunModulesEvaluator class and its constructor parameters
rg -A 5 "class BunModulesEvaluator" packages/core/src/module-runner/evaluator.ts
EOF
bash find_bun_evaluator.sh

Repository: farm-fe/farm

Length of output: 140


🏁 Script executed:

# Check the BaseAsyncFunctionEvaluator constructor to see what allowCommonJsRequire parameter does
rg -B 5 -A 10 "constructor.*allowCommonJsRequire" packages/core/src/module-runner/evaluator.ts

Repository: farm-fe/farm

Length of output: 38


🏁 Script executed:

# Find where nodeCommonJsLoader is used to understand the implications
rg -B 2 -A 5 "nodeCommonJsLoader" packages/core/src/module-runner/evaluator.ts

Repository: farm-fe/farm

Length of output: 1249


🏁 Script executed:

find . -name "evaluator.ts" -type f 2>/dev/null

Repository: farm-fe/farm

Length of output: 102


🏁 Script executed:

# List the module-runner directory structure
find . -path "*/module-runner*" -type d 2>/dev/null | head -20

Repository: farm-fe/farm

Length of output: 89


🏁 Script executed:

# Get the BunModulesEvaluator class definition
sed -n '500,522p' ./packages/core/src/module-runner/evaluator.ts

Repository: farm-fe/farm

Length of output: 817


🏁 Script executed:

# Get all evaluator class definitions for comparison
sed -n '500,522p' ./packages/core/src/module-runner/evaluator.ts | cat -n
# Also get ESModulesEvaluator and others
rg -A 3 "class (BunModulesEvaluator|DenoModulesEvaluator|WorkerModulesEvaluator|ESModulesEvaluator)" ./packages/core/src/module-runner/evaluator.ts

Repository: farm-fe/farm

Length of output: 1738


BunModulesEvaluator should enable CommonJS support — Bun natively supports require() in ESM contexts.

BunModulesEvaluator passes allowCommonJsRequire: false, but Bun has built-in support for require() from ES modules. Unlike Node.js strict ESM, Bun explicitly allows calling require() for both CJS and ESM modules. SSR modules that depend on CommonJS-only packages will fail with "commonjs external module requires Node module loader support" instead of loading via Bun's native require, even though Bun can handle this natively. Change to super('bun', true, resolveExternalModule) to align with ESModulesEvaluator and Bun's actual capabilities.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/module-runner/evaluator.ts` around lines 512 - 516,
BunModulesEvaluator currently calls super('bun', false, resolveExternalModule)
which disables CommonJS require support; update the BunModulesEvaluator
constructor to call super('bun', true, resolveExternalModule) so Bun's native
require() in ESM contexts is enabled (modify the constructor in the
BunModulesEvaluator class that extends BaseAsyncFunctionEvaluator to pass true
for allowCommonJsRequire).

Comment on lines +72 to +91
const changedModules = [
...result.changed,
...result.added,
...result.extraWatchResult.add
];

if (changedModules.length > 0) {
const updates: RunnerHotUpdate[] = changedModules.map((item) => ({
type: 'js-update',
path: item,
acceptedPath: item,
timestamp: Date.now()
}));

onMessage({
type: 'update',
updates
} satisfies RunnerHotPayload);
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, examine the file structure and the full function context
cd packages/core/src/module-runner && wc -l serverTransport.ts

Repository: farm-fe/farm

Length of output: 78


🏁 Script executed:

# Get the complete function context around these lines
cd packages/core/src/module-runner && sed -n '60,110p' serverTransport.ts

Repository: farm-fe/farm

Length of output: 1318


🏁 Script executed:

# Check JsUpdateResult type definition
rg -n 'type JsUpdateResult|interface JsUpdateResult' packages/core/src/types/ -A15

Repository: farm-fe/farm

Length of output: 38


🏁 Script executed:

# Search for how removed is used in this context
rg -n 'result\.removed' packages/core/src/module-runner/serverTransport.ts -B5 -A5

Repository: farm-fe/farm

Length of output: 341


🏁 Script executed:

# Search more broadly for JsUpdateResult definition
rg -n 'JsUpdateResult' packages/core/src/ -B2 -A10 | head -100

Repository: farm-fe/farm

Length of output: 6895


🏁 Script executed:

# Search in binding.ts or types directory
find packages/core/src -name "*.ts" -type f | xargs grep -l "JsUpdateResult" | head -5

Repository: farm-fe/farm

Length of output: 250


🏁 Script executed:

# Check how the hmrEngine creates JsUpdateResult
rg -n 'onUpdateFinish|JsUpdateResult' packages/core/src -B3 -A3 | head -80

Repository: farm-fe/farm

Length of output: 5486


🏁 Script executed:

# Find the JsUpdateResult type definition
find packages -name "*.ts" -path "*/types/*" | xargs grep -l "JsUpdateResult"

Repository: farm-fe/farm

Length of output: 40


🏁 Script executed:

# Look for binding.ts or similar
fd -e ts "binding" packages/core/src/types/

Repository: farm-fe/farm

Length of output: 90


🏁 Script executed:

# Search for the type/interface definition more broadly
rg -n "type JsUpdateResult|interface JsUpdateResult|export.*JsUpdateResult" packages/core/src/types/ -A20

Repository: farm-fe/farm

Length of output: 38


🏁 Script executed:

# Read the binding.ts file to find JsUpdateResult definition
cat -n packages/core/src/types/binding.ts | grep -A25 "JsUpdateResult"

Repository: farm-fe/farm

Length of output: 38


🏁 Script executed:

# Get more context on JsUpdateResult
rg "JsUpdateResult" packages/core/src/types/binding.ts -A20 -B2

Repository: farm-fe/farm

Length of output: 38


🏁 Script executed:

# Let's read the binding.ts file directly
cat packages/core/src/types/binding.ts

Repository: farm-fe/farm

Length of output: 18152


🏁 Script executed:

# Find the binding directory and look for type definitions
find packages -type d -name "binding" | head -5

Repository: farm-fe/farm

Length of output: 114


🏁 Script executed:

# Search for JsUpdateResult in all files to understand its structure
rg "removed.*:" packages/core/src/server/hmr-engine.ts -B5 -A5

Repository: farm-fe/farm

Length of output: 518


🏁 Script executed:

# Look at how updateResult is used in hmr-engine and watcher
rg "result\.removed|result\.changed|result\.added" packages/core/src/ -B2 -A2 | head -80

Repository: farm-fe/farm

Length of output: 869


🏁 Script executed:

# Look at how the result object is constructed in hmr-engine
sed -n '32,120p' packages/core/src/server/hmr-engine.ts

Repository: farm-fe/farm

Length of output: 2822


🏁 Script executed:

# Check if there are any scenarios where both changed and removed could be non-empty
rg "callUpdates|recompileAndSendResult" packages/core/src/server/hmr-engine.ts -B3 -A15 | head -100

Repository: farm-fe/farm

Length of output: 2521


When an update contains both changed/added modules and removals, removals are silently dropped.

The early return at line 90 prevents the result.removed check at line 93 from executing when changedModules is non-empty. Since the compiler can populate changed, added, and removed independently in a single update batch, removals may be lost if concurrent with any changes or additions, leaving stale code in the module cache.

Consider whether removals should be handled independently (perhaps by triggering a full reload when any removals are present), or by processing both branches regardless of the presence of changes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/module-runner/serverTransport.ts` around lines 72 - 91, The
current logic builds changedModules and returns early after sending a 'update'
via onMessage, which causes result.removed to be ignored; update the
serverTransport handler so removals are not dropped—either remove the early
return and process result.removed after sending the 'update' (sending a separate
removal payload or invoking the existing removal handling), or detect any
removals and trigger a full reload (e.g., send a 'full-reload' RunnerHotPayload)
when result.removed.length > 0; ensure you reference changedModules,
result.removed, onMessage, RunnerHotUpdate and RunnerHotPayload so both
changes/additions and removals are handled in the same update batch.

Comment on lines +96 to +103
};

server.hmrEngine?.onUpdateFinish(updateListener);

disconnectHandler = () => {
active = false;
onDisconnection();
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Find the serverTransport.ts file
fd -type f -name "serverTransport.ts"

Repository: farm-fe/farm

Length of output: 226


🏁 Script executed:

# Find the serverTransport.ts file
fd "serverTransport.ts"

Repository: farm-fe/farm

Length of output: 106


🏁 Script executed:

# Read the serverTransport.ts file to examine lines 96-103 and surrounding context
cat -n packages/core/src/module-runner/serverTransport.ts

Repository: farm-fe/farm

Length of output: 3602


🏁 Script executed:

# Find hmrEngine definition - search for the HmrEngine interface or class
fd -name "*.ts" | xargs rg "interface.*HmrEngine|class.*HmrEngine" -A 5

Repository: farm-fe/farm

Length of output: 730


🏁 Script executed:

# Find the onUpdateFinish method definition in hmr-engine.ts
rg "onUpdateFinish" packages/core/src/server/hmr-engine.ts -A 10

Repository: farm-fe/farm

Length of output: 720


🏁 Script executed:

# Check how _onUpdates array is used and if there's any removal mechanism
rg "_onUpdates" packages/core/src/server/hmr-engine.ts

Repository: farm-fe/farm

Length of output: 251


🏁 Script executed:

# Read the full HmrEngine class to understand the listener registration and usage
sed -n '1,200p' packages/core/src/server/hmr-engine.ts | cat -n

Repository: farm-fe/farm

Length of output: 6894


🏁 Script executed:

# Check if there's any method to remove listeners from HmrEngine
rg "removeListener|unsubscribe|_onUpdates.splice|_onUpdates.filter" packages/core/src/server/hmr-engine.ts

Repository: farm-fe/farm

Length of output: 38


Listener leak: updateListener is never removed from hmrEngine on disconnect.

In createModuleRunnerTransportFromInvokeHandlers (line 40), unsubscribe() properly removes the hot-bus listener. Here, the updateListener registered via server.hmrEngine?.onUpdateFinish() has no removal mechanism — active = false only prevents message forwarding, but the callback remains in the HmrEngine's _onUpdates array indefinitely. On repeated connect/disconnect cycles, listeners accumulate and are invoked on every HMR update, causing a memory leak.

The proposed fix in the original comment is incorrect: HmrEngine.onUpdateFinish() returns void, not an unsubscribe function. A proper fix requires modifying HmrEngine to support listener removal (e.g., return an unsubscribe function from onUpdateFinish, add a removeListener method, or similar), then use that mechanism in the disconnectHandler.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/module-runner/serverTransport.ts` around lines 96 - 103,
The registered updateListener passed to server.hmrEngine?.onUpdateFinish(...) is
never removed on disconnect which leaks listeners; update HmrEngine to support
unsubscribing (e.g., change onUpdateFinish to return an unsubscribe function or
add a removeOnUpdateFinish/removeListener API) and then capture that unsubscribe
when calling server.hmrEngine?.onUpdateFinish(...) inside
createModuleRunnerTransportFromInvokeHandlers; call the unsubscribe (or
appropriate remove method) from disconnectHandler instead of only setting active
= false so the listener is actually removed on disconnect (also mirror the
pattern used by unsubscribe() for the hot-bus listener).

Comment on lines 723 to 727
} else {
result.push(...mods.map((mod) => mod.id));
for (const mod of mods) {
pushResult(mod.id, updateType);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

mods is iterated without a null guard — runtime crash when getModulesByFile returns undefined.

Vite's getModulesByFile signature is (file: string): Set<ModuleNode> | undefined. The as unknown as ModuleNode[] cast on line 695 hides this. The HmrContext construction on line 703 is already null-safe (mods ?? []), but the fallback loop below is not:

} else {
  for (const mod of mods) {   // 💥 TypeError when mods is undefined

If a file update arrives for a path not in the module graph and the plugin's handleHotUpdate returns undefined (or the plugin has no hook), this path crashes.

🛡️ Proposed fix
- for (const mod of mods) {
+ for (const mod of (mods ?? [])) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/plugin/js/vite-plugin-adapter.ts` around lines 723 - 727,
The code iterates over mods without guarding for undefined, causing a crash when
getModulesByFile returns undefined; update the fallback branch so it uses a
null-safe iteration (e.g., iterate over mods ?? [] or check if mods is truthy)
before calling pushResult; modify the block containing the for (const mod of
mods) loop in vite-plugin-adapter.ts to use the same null-safe pattern already
used when constructing HmrContext (referencing getModulesByFile, mods, and
pushResult) so it no longer throws when mods is undefined.

Comment thread packages/ssr/src/build-preview.ts Outdated
Comment on lines +491 to +501
} else {
const placeholder = templateOptions.placeholder ?? '<!--app-html-->';

if (!template.includes(placeholder)) {
throw new Error(
`[farm ssr] template placeholder "${placeholder}" was not found.`
);
}

html = template.replace(placeholder, appHtml);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

String.replace interprets $ replacement patterns in appHtml.

If the SSR-rendered appHtml contains dollar-sign sequences ($&, $`, $', $1$9), template.replace(placeholder, appHtml) will interpret them as special replacement patterns, silently corrupting the output. Use a function replacer to avoid this.

Proposed fix
-        html = template.replace(placeholder, appHtml);
+        html = template.replace(placeholder, () => appHtml);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} else {
const placeholder = templateOptions.placeholder ?? '<!--app-html-->';
if (!template.includes(placeholder)) {
throw new Error(
`[farm ssr] template placeholder "${placeholder}" was not found.`
);
}
html = template.replace(placeholder, appHtml);
}
} else {
const placeholder = templateOptions.placeholder ?? '<!--app-html-->';
if (!template.includes(placeholder)) {
throw new Error(
`[farm ssr] template placeholder "${placeholder}" was not found.`
);
}
html = template.replace(placeholder, () => appHtml);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ssr/src/build-preview.ts` around lines 491 - 501, The template
replacement uses template.replace(placeholder, appHtml) which allows $-sequences
in appHtml to be treated as special replacement patterns; update the replacement
in build-preview.ts to use a function replacer so the raw appHtml is inserted
verbatim (e.g., call template.replace(placeholder, () => appHtml)); reference
templateOptions.placeholder, placeholder, template, appHtml and html when making
the change so the placeholder check remains and html is set from the
function-based replacement to avoid $-pattern interpretation.

Comment thread packages/ssr/src/dev-server.ts Outdated
Comment on lines +696 to +703
const placeholder = params.options.template.placeholder ?? '<!--app-html-->';
if (!template.includes(placeholder)) {
throw new Error(
`[farm ssr] template placeholder "${placeholder}" was not found.`
);
}

return template.replace(placeholder, appHtml);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Same String.replace $-pattern issue as in build-preview.ts.

template.replace(placeholder, appHtml) on line 703 will misinterpret dollar-sign sequences in appHtml as replacement patterns. Use a function replacer.

Proposed fix
-  return template.replace(placeholder, appHtml);
+  return template.replace(placeholder, () => appHtml);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const placeholder = params.options.template.placeholder ?? '<!--app-html-->';
if (!template.includes(placeholder)) {
throw new Error(
`[farm ssr] template placeholder "${placeholder}" was not found.`
);
}
return template.replace(placeholder, appHtml);
const placeholder = params.options.template.placeholder ?? '<!--app-html-->';
if (!template.includes(placeholder)) {
throw new Error(
`[farm ssr] template placeholder "${placeholder}" was not found.`
);
}
return template.replace(placeholder, () => appHtml);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ssr/src/dev-server.ts` around lines 696 - 703, The template
replacement uses template.replace(placeholder, appHtml) which treats $ sequences
in appHtml as replacement patterns; update the replacement to use a function
replacer so appHtml is inserted verbatim (e.g., change the call in the block
that defines placeholder and returns template.replace(...) to use a function
that returns appHtml), ensuring any $ in appHtml are not interpreted; keep the
same placeholder constant and return value.

Comment on lines +32 to +37
interface SsrServerResolvers {
createDevServer(options: SsrDevServerOptions): Promise<SsrDevServer>;
createPreviewServer(options: SsrPreviewOptions): Promise<SsrPreviewServer>;
startDevServer(options: SsrDevServerOptions): Promise<SsrDevServer>;
startPreviewServer(options: SsrPreviewOptions): Promise<SsrPreviewServer>;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

SsrServerResolvers must be exported — it's a parameter type of two exported functions.

createSsrServerWithResolvers and startSsrServerWithResolvers are public API, but consumers cannot declare a typed variable for their custom resolvers because the interface isn't accessible. They are forced to use the awkward Parameters<typeof createSsrServerWithResolvers>[1] workaround.

♻️ Proposed fix
-interface SsrServerResolvers {
+export interface SsrServerResolvers {
   createDevServer(options: SsrDevServerOptions): Promise<SsrDevServer>;
   createPreviewServer(options: SsrPreviewOptions): Promise<SsrPreviewServer>;
   startDevServer(options: SsrDevServerOptions): Promise<SsrDevServer>;
   startPreviewServer(options: SsrPreviewOptions): Promise<SsrPreviewServer>;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
interface SsrServerResolvers {
createDevServer(options: SsrDevServerOptions): Promise<SsrDevServer>;
createPreviewServer(options: SsrPreviewOptions): Promise<SsrPreviewServer>;
startDevServer(options: SsrDevServerOptions): Promise<SsrDevServer>;
startPreviewServer(options: SsrPreviewOptions): Promise<SsrPreviewServer>;
}
export interface SsrServerResolvers {
createDevServer(options: SsrDevServerOptions): Promise<SsrDevServer>;
createPreviewServer(options: SsrPreviewOptions): Promise<SsrPreviewServer>;
startDevServer(options: SsrDevServerOptions): Promise<SsrDevServer>;
startPreviewServer(options: SsrPreviewOptions): Promise<SsrPreviewServer>;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ssr/src/server.ts` around lines 32 - 37, The interface
SsrServerResolvers is currently unexported but is used as the parameter type for
the public functions createSsrServerWithResolvers and
startSsrServerWithResolvers; export the interface (export interface
SsrServerResolvers) so consumers can import and declare typed custom resolvers
instead of using Parameters<typeof createSsrServerWithResolvers>[1]; update any
related re-exports if needed so SsrServerResolvers is part of the package's
public API surface.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

♻️ Duplicate comments (2)
packages/ssr/src/build-preview.ts (1)

532-540: ⚠️ Potential issue | 🟠 Major

Use a function replacer for appHtml.

Line 540 passes SSR output as a string replacement, so $&, $1, and similar sequences inside appHtml are interpreted by String.replace and can corrupt the response body. Switch this to template.replace(placeholder, () => appHtml).

Proposed fix
-        html = template.replace(placeholder, appHtml);
+        html = template.replace(placeholder, () => appHtml);
What replacement patterns does JavaScript `String.prototype.replace` interpret when the replacement value is a string?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ssr/src/build-preview.ts` around lines 532 - 540, The current code
uses template.replace(placeholder, appHtml) which treats appHtml as a string
replacement so sequences like $&, $1, etc. inside appHtml can be interpreted and
corrupt the output; change the call to use a function replacer instead (i.e.,
call template.replace(placeholder, () => appHtml)) so appHtml is inserted
verbatim. Update the replacement where template, placeholder (from
templateOptions.placeholder) and appHtml are used and ensure template.replace is
the only change.
packages/ssr/src/dev-server.ts (1)

590-614: ⚠️ Potential issue | 🟠 Major

Inject appHtml verbatim and preserve the matched root container.

Both replacement branches pass appHtml to String.replace as a plain string, so $ sequences in SSR output are interpreted as replacement patterns. The fallback branch also recreates <div id="root"> from scratch and drops any existing attributes on the matched root node. Use function replacers and reuse the original opening/closing tags.

Proposed fix
-const ROOT_CONTAINER_PATTERN =
-  /<div\s+id=(?:"root"|'root'|root)[^>]*>[\s\S]*?<\/div>/;
+const ROOT_CONTAINER_PATTERN =
+  /(<div\s+id=(?:"root"|'root'|root)[^>]*>)[\s\S]*?(<\/div>)/;
@@
   if (params.template.includes(params.placeholder)) {
-    return params.template.replace(params.placeholder, params.appHtml);
+    return params.template.replace(params.placeholder, () => params.appHtml);
   }
@@
   return params.template.replace(
     ROOT_CONTAINER_PATTERN,
-    `<div id="root">${params.appHtml}</div>`
+    (_match, openTag: string, closeTag: string) =>
+      `${openTag}${params.appHtml}${closeTag}`
   );
 }
What replacement patterns does JavaScript `String.prototype.replace` interpret when the replacement value is a string?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ssr/src/dev-server.ts` around lines 590 - 614, The
injectAppHtmlToTemplate function currently passes appHtml as a plain string to
String.replace, which treats $ sequences as replacement patterns and rebuilds
the root node losing attributes; change both replace calls to use a function
replacer so appHtml is inserted verbatim (use () => params.appHtml for the
placeholder branch) and change the ROOT_CONTAINER_PATTERN replace to a function
replacer that reuses the matched opening/closing tag groups from the regex match
(return opening + params.appHtml + closing) instead of reconstructing a <div
id="root">, ensuring the original attributes and tags from the regex match are
preserved and no $-escape interpretation occurs.
🧹 Nitpick comments (2)
packages/core/src/module-runner/runner.ts (2)

268-281: Consider handling HMR connection errors.

The HMR connection is initiated with void to ignore the promise, but if transport.connect throws or fails, the error will be silently swallowed. Consider adding error handling for robustness during development.

♻️ Suggested improvement
     if (options.hmr !== false) {
       if (!this.options.transport.connect) {
         throw new Error(
           '[farm module runner] HMR is enabled but transport.connect is not available.'
         );
       }

-      void this.options.transport.connect({
+      this.options.transport.connect({
         onMessage: (payload) => this.handleHotPayload(payload),
         onDisconnection: () => {
           // No-op for now. Keep room for future reconnect strategy.
         }
+      }).catch((err) => {
+        console.warn('[farm module runner] HMR connection failed:', err);
       });
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/module-runner/runner.ts` around lines 268 - 281, The HMR
connect promise is ignored (void this.options.transport.connect(...)) so
connection failures are swallowed; update the HMR block (the options.hmr check
and this.options.transport.connect call) to handle rejections by awaiting or
attaching .catch and logging/propagating errors and optionally retrying; ensure
the connect invocation still passes the same callbacks (onMessage ->
this.handleHotPayload, onDisconnection) but add a .catch handler or try/catch
around the await to call the appropriate logger/error handler and avoid silent
failures.

614-622: import.meta.resolve throws on async resolver — document or handle gracefully.

Line 1293-1296 shows that resolveImportMetaRequestSync throws if the resolver returns a promise. This is correct per spec (import.meta.resolve is sync in most runtimes), but the error message could be clearer about the expected behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/module-runner/runner.ts` around lines 614 - 622, The code
currently sets importMeta.resolve to call resolveImportMetaRequestSync which
throws if a custom resolver returns a Promise; update
resolveImportMetaRequestSync to throw a clearer, actionable error message
stating that import.meta.resolve must be synchronous and that the provided
resolver returned a Promise (include the resolver result/type), and also update
any related docs/comments near createImportMeta and importMeta.resolve to state
that options.createImportMeta/resolver must be synchronous; ensure the thrown
error references resolveImportMetaRequestSync and importMeta.resolve so callers
can locate the issue.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@crates/node/src/lib.rs`:
- Around line 973-986: The code currently calls clean_url(&raw_id).to_string()
and then returns external_fetch_result(clean_id, ...) which strips
query/fragment; instead, keep using clean_url(&raw_id) only for lookup but
preserve and reattach the original specifier's query/fragment when calling
external_fetch_result or building the returned file URL: extract the
query/fragment from raw_id (or retain raw_id) and append it to the generated URL
for data:, node:, http/https and other branches inside the externalize logic
(functions/variables: clean_url, raw_id, external_fetch_result, externalize) so
the returned externalized specifier retains the original query/fragment while
lookups still use the cleaned form.
- Around line 146-158: The hand-built path_to_file_url function must be replaced
to produce a proper percent-encoded file URL: use the url crate's
Url::from_file_path(path) (or equivalent std/OS-safe API) to convert the Path
into a file:// URL and return Url::to_string(), and handle the Result/boolean
failure path (e.g., return an error or panic with a clear message) instead of
manual string replacement; update the path_to_file_url implementation to call
Url::from_file_path(path) and return the encoded Url string so spaces, #, %, and
non-ASCII characters are correctly percent-encoded.
- Around line 966-971: The bridge currently accepts JsFetchModuleOptions in
fetch_module but ignores its fields (cached and start_offset); either implement
their behavior or remove the option from the N-API boundary. Fix by updating pub
fn fetch_module(&self, id: String, importer: Option<String>, _options:
Option<JsFetchModuleOptions>) -> Option<JsFetchModuleResult> to read the Option,
extract cached and start_offset from JsFetchModuleOptions, and pass them into
the internal fetch logic (or into whatever helper like
fetch_module_internal/fetch_from_cache is used) so cached toggles returning a
cached result and start_offset is applied when constructing the module result;
alternatively, remove the JsFetchModuleOptions parameter and all related
bindings so callers cannot pass unused options. Ensure you reference the
JsFetchModuleOptions fields (cached, start_offset) and the fetch_module function
when making the change.
- Around line 196-203: The current code uses importer_path.is_file() to decide
the base_dir which misclassifies non-existent module ids; change the logic to
treat the importer path as a directory only when importer_path.is_dir() (or it
explicitly indicates a directory), otherwise use
importer_path.parent().unwrap_or(importer_path.as_path()) as the base directory;
update the branch in the block that computes base_dir (and the analogous logic
in resolve_base_dir) to use is_dir() instead of is_file() and default to
parent() so relative imports resolve correctly for module ids that don't exist
on disk.

In `@packages/core/src/module-runner/evaluatedModules.ts`:
- Around line 74-90: The removeModule function removes the module from
idToModuleMap, urlToModuleMap and fileToModulesMap but doesn’t detach graph
edges, leaving stale references in neighbors' imports/importers; before deleting
entries in removeModule(EvaluatedModuleNode mod) call the existing
invalidateModule(mod) (or explicitly iterate mod.imports and mod.importers to
remove this mod from each neighbor’s sets) to remove all inbound/outbound edges
so the graph stays consistent.

In `@packages/core/src/module-runner/sourceMapInterceptor.ts`:
- Around line 131-142: The current global iteration over globalHookStores in
lookupSourceMap()/applyCustomStackFormatter() lets hooks from other runners
(retrieveSourceMap/formatStack) intercept stacks; restrict iterations to the
hook store(s) owned by the current runner instead of walking all entries. Change
the loops that use globalHookStores.entries() (and the calls to
resolveHookSourceCandidate, sourceCandidates and resolveHookSourceMap) to filter
by the runner identity (e.g., entry.owner or compare entry.runnerId to the
current runner context) or accept a runnerId parameter and only examine the
matching store(s) so only hooks registered by that runner are consulted; apply
the same scoping change to the other occurrences you flagged (around the blocks
at 269-288 and 476-479).

In `@packages/ssr/src/build-preview.ts`:
- Around line 117-149: The regex-only approach in rewriteModuleImportsForDataUrl
leaves relative specifiers in export ... from and dynamic import(...) forms,
which will break when code is loaded from a data: URL; update
rewriteModuleImportsForDataUrl so all relative ESM specifiers are rewritten to
absolute file:// URLs: either (A) parse the module (e.g., with a lightweight
parser or acorn/estree) and replace specifiers for ImportDeclaration,
ExportNamedDeclaration/ExportAllDeclaration and ImportExpression nodes, using
pathToFileURL(path.resolve(moduleDir, specifier)).href and keep the
createRequire replacement using originalFileUrl, or (B) avoid data: URLs
entirely by writing the transformed code to a temporary file and use its file://
URL as the module entry instead of the data: URL; adjust the code paths that
call rewriteModuleImportsForDataUrl (and the createRequire(import.meta.url)
replacement) accordingly.

In `@packages/ssr/src/dev-server.ts`:
- Around line 505-549: The watcher handlers in
watchServerCompilerWithSingleWatcher (the params.watcher.on
'add'/'unlink'/'change' callbacks and runUpdate calling params.compiler.update
and params.onUpdateResult) can run concurrently and overwrite shared
invokeContext; serialize/coalesce these updates by introducing a single async
FIFO processor: queue incoming events (path+type) from the watcher callbacks,
have a single loop/task that dequeues events and runs params.compiler.update
sequentially, and coalesce bursty events (e.g., collapse multiple events for the
same path to the latest type) before calling params.onUpdateResult so only one
update runs at a time and only the latest state is applied to the shared
invokeContext. Ensure runUpdate is adapted to enqueue rather than directly
invoke params.compiler.update and that the processor respects the active flag
and drains/cleans the queue on shutdown.
- Around line 463-489: The emitRunnerPayload function currently returns an
'update' when changed/added exist and skips 'full-reload' even if result.removed
is non-empty; change the control flow in emitRunnerPayload so that if
result.removed.length > 0 you emit a { type: 'full-reload' } (and return) before
emitting any 'update' payloads, or otherwise ensure the presence of removals
forces a full reload; update logic around the changedModules calculation and the
early return to check result.removed first (refer to emitRunnerPayload,
changedModules, and result.removed).

---

Duplicate comments:
In `@packages/ssr/src/build-preview.ts`:
- Around line 532-540: The current code uses template.replace(placeholder,
appHtml) which treats appHtml as a string replacement so sequences like $&, $1,
etc. inside appHtml can be interpreted and corrupt the output; change the call
to use a function replacer instead (i.e., call template.replace(placeholder, ()
=> appHtml)) so appHtml is inserted verbatim. Update the replacement where
template, placeholder (from templateOptions.placeholder) and appHtml are used
and ensure template.replace is the only change.

In `@packages/ssr/src/dev-server.ts`:
- Around line 590-614: The injectAppHtmlToTemplate function currently passes
appHtml as a plain string to String.replace, which treats $ sequences as
replacement patterns and rebuilds the root node losing attributes; change both
replace calls to use a function replacer so appHtml is inserted verbatim (use ()
=> params.appHtml for the placeholder branch) and change the
ROOT_CONTAINER_PATTERN replace to a function replacer that reuses the matched
opening/closing tag groups from the regex match (return opening + params.appHtml
+ closing) instead of reconstructing a <div id="root">, ensuring the original
attributes and tags from the regex match are preserved and no $-escape
interpretation occurs.

---

Nitpick comments:
In `@packages/core/src/module-runner/runner.ts`:
- Around line 268-281: The HMR connect promise is ignored (void
this.options.transport.connect(...)) so connection failures are swallowed;
update the HMR block (the options.hmr check and this.options.transport.connect
call) to handle rejections by awaiting or attaching .catch and
logging/propagating errors and optionally retrying; ensure the connect
invocation still passes the same callbacks (onMessage -> this.handleHotPayload,
onDisconnection) but add a .catch handler or try/catch around the await to call
the appropriate logger/error handler and avoid silent failures.
- Around line 614-622: The code currently sets importMeta.resolve to call
resolveImportMetaRequestSync which throws if a custom resolver returns a
Promise; update resolveImportMetaRequestSync to throw a clearer, actionable
error message stating that import.meta.resolve must be synchronous and that the
provided resolver returned a Promise (include the resolver result/type), and
also update any related docs/comments near createImportMeta and
importMeta.resolve to state that options.createImportMeta/resolver must be
synchronous; ensure the thrown error references resolveImportMetaRequestSync and
importMeta.resolve so callers can locate the issue.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2a95f751-30b8-4175-92b1-35b792398ab6

📥 Commits

Reviewing files that changed from the base of the PR and between 2ee0be3 and f6dfeeb.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (22)
  • crates/node/src/lib.rs
  • crates/node/src/module_runner_transform.rs
  • crates/plugin_library/src/formats/umd.rs
  • packages/core/src/module-runner/diagnostics.ts
  • packages/core/src/module-runner/evaluatedModules.ts
  • packages/core/src/module-runner/index.ts
  • packages/core/src/module-runner/runner.ts
  • packages/core/src/module-runner/sourceMapInterceptor.ts
  • packages/core/src/module-runner/types.ts
  • packages/core/tests/module-runner.benchmark.spec.ts
  • packages/core/tests/module-runner.convergence.spec.ts
  • packages/core/tests/module-runner.diagnostics.spec.ts
  • packages/core/tests/module-runner.exports.spec.ts
  • packages/core/tests/module-runner.hmr.spec.ts
  • packages/core/tests/module-runner.import-meta.spec.ts
  • packages/core/tests/module-runner.non-js.spec.ts
  • packages/core/tests/module-runner.resolver.spec.ts
  • packages/core/tests/module-runner.sourcemap.spec.ts
  • packages/ssr/src/build-preview.ts
  • packages/ssr/src/dev-server.ts
  • packages/ssr/test/build-preview.spec.ts
  • packages/ssr/test/dev-server.spec.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/core/tests/module-runner.benchmark.spec.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/ssr/test/dev-server.spec.ts
  • packages/core/src/module-runner/index.ts

Comment thread crates/node/src/lib.rs
Comment on lines +146 to +158
fn path_to_file_url(path: &Path) -> String {
let normalized = path.to_string_lossy().replace('\\', "/");

if cfg!(windows) {
if normalized.starts_with('/') {
format!("file://{normalized}")
} else {
format!("file:///{normalized}")
}
} else {
format!("file://{normalized}")
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use a real file-URL encoder here.

This hand-built file:// string does not percent-encode spaces, #, %, or non-ASCII characters. A path containing reserved characters will externalize to the wrong URL and can fail to load on the JS side.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/node/src/lib.rs` around lines 146 - 158, The hand-built
path_to_file_url function must be replaced to produce a proper percent-encoded
file URL: use the url crate's Url::from_file_path(path) (or equivalent
std/OS-safe API) to convert the Path into a file:// URL and return
Url::to_string(), and handle the Result/boolean failure path (e.g., return an
error or panic with a clear message) instead of manual string replacement;
update the path_to_file_url implementation to call Url::from_file_path(path) and
return the encoded Url string so spaces, #, %, and non-ASCII characters are
correctly percent-encoded.

Comment thread crates/node/src/lib.rs
Comment on lines +196 to +203
if id.starts_with("./") || id.starts_with("../") {
let importer_path = normalize_importer_path(importer, root)?;
let base_dir = if importer_path.is_file() {
importer_path.parent().unwrap_or(importer_path.as_path())
} else {
importer_path.as_path()
};
return Some(base_dir.join(id));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't use is_file() to infer importer semantics.

Many importers in this flow are module ids that are valid logically but don't exist on disk yet. In that case is_file() is false, so ./dep gets resolved as if /src/entry.mjs were a directory, producing /src/entry.mjs/dep. The same heuristic is reused for resolve_base_dir, so relative fetches can break before resolver fallback helps.

Also applies to: 1031-1042

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/node/src/lib.rs` around lines 196 - 203, The current code uses
importer_path.is_file() to decide the base_dir which misclassifies non-existent
module ids; change the logic to treat the importer path as a directory only when
importer_path.is_dir() (or it explicitly indicates a directory), otherwise use
importer_path.parent().unwrap_or(importer_path.as_path()) as the base directory;
update the branch in the block that computes base_dir (and the analogous logic
in resolve_base_dir) to use is_dir() instead of is_file() and default to
parent() so relative imports resolve correctly for module ids that don't exist
on disk.

Comment thread crates/node/src/lib.rs
Comment on lines +966 to +971
pub fn fetch_module(
&self,
id: String,
importer: Option<String>,
_options: Option<JsFetchModuleOptions>,
) -> Option<JsFetchModuleResult> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Either honor JsFetchModuleOptions or drop it from the bridge.

cached and start_offset are accepted at the N-API boundary but never read, so the new options are currently a silent no-op for callers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/node/src/lib.rs` around lines 966 - 971, The bridge currently accepts
JsFetchModuleOptions in fetch_module but ignores its fields (cached and
start_offset); either implement their behavior or remove the option from the
N-API boundary. Fix by updating pub fn fetch_module(&self, id: String, importer:
Option<String>, _options: Option<JsFetchModuleOptions>) ->
Option<JsFetchModuleResult> to read the Option, extract cached and start_offset
from JsFetchModuleOptions, and pass them into the internal fetch logic (or into
whatever helper like fetch_module_internal/fetch_from_cache is used) so cached
toggles returning a cached result and start_offset is applied when constructing
the module result; alternatively, remove the JsFetchModuleOptions parameter and
all related bindings so callers cannot pass unused options. Ensure you reference
the JsFetchModuleOptions fields (cached, start_offset) and the fetch_module
function when making the change.

Comment thread crates/node/src/lib.rs
Comment on lines +973 to +986
let clean_id = clean_url(&raw_id).to_string();
let has_query = raw_id.contains('?');

if clean_id.starts_with("data:") {
return Some(external_fetch_result(clean_id, "builtin".to_string(), None));
}

if clean_id.starts_with("node:") {
return Some(external_fetch_result(clean_id, "builtin".to_string(), None));
}

if clean_id.starts_with("http://") || clean_id.starts_with("https://") {
return Some(external_fetch_result(clean_id, "network".to_string(), None));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve query/fragment in externalize.

These paths clean or canonicalize the specifier before returning externalize, so imports like https://cdn/x.js?worker, data: URLs, or local files with ?raw/?url lose part of their identity. That changes module semantics and cache keys for the runner. Use the cleaned value only for lookup; return the original specifier or re-attach its query/fragment to the generated file URL.

Also applies to: 1018-1024, 1072-1077, 1085-1105, 1109-1114

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/node/src/lib.rs` around lines 973 - 986, The code currently calls
clean_url(&raw_id).to_string() and then returns external_fetch_result(clean_id,
...) which strips query/fragment; instead, keep using clean_url(&raw_id) only
for lookup but preserve and reattach the original specifier's query/fragment
when calling external_fetch_result or building the returned file URL: extract
the query/fragment from raw_id (or retain raw_id) and append it to the generated
URL for data:, node:, http/https and other branches inside the externalize logic
(functions/variables: clean_url, raw_id, external_fetch_result, externalize) so
the returned externalized specifier retains the original query/fragment while
lookups still use the cleaned form.

Comment on lines +74 to +90
removeModule(mod: EvaluatedModuleNode): void {
this.idToModuleMap.delete(mod.id);

for (const [url, current] of this.urlToModuleMap.entries()) {
if (current === mod) {
this.urlToModuleMap.delete(url);
}
}

const fileKey = cleanUrl(mod.id);
const fileMatched = this.fileToModulesMap.get(fileKey);
if (fileMatched) {
fileMatched.delete(mod);
if (fileMatched.size === 0) {
this.fileToModulesMap.delete(fileKey);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Detach graph edges before deleting the node.

removeModule() drops the maps, but it never removes this module from its neighbors' imports/importers sets. After a prune, the graph can still contain stale edges to a module that no longer exists. Calling invalidateModule(mod) first would keep the graph consistent.

Suggested fix
  removeModule(mod: EvaluatedModuleNode): void {
+   this.invalidateModule(mod);
    this.idToModuleMap.delete(mod.id);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/module-runner/evaluatedModules.ts` around lines 74 - 90,
The removeModule function removes the module from idToModuleMap, urlToModuleMap
and fileToModulesMap but doesn’t detach graph edges, leaving stale references in
neighbors' imports/importers; before deleting entries in
removeModule(EvaluatedModuleNode mod) call the existing invalidateModule(mod)
(or explicitly iterate mod.imports and mod.importers to remove this mod from
each neighbor’s sets) to remove all inbound/outbound edges so the graph stays
consistent.

Comment on lines +131 to +142
const hookEntries = [...globalHookStores.entries()].reverse();
for (const [, entry] of hookEntries) {
for (const candidate of candidates) {
const resolved = resolveHookSourceCandidate(entry.hooks, candidate);
for (const hookCandidate of sourceCandidates(resolved)) {
const fromHook = resolveHookSourceMap(entry, hookCandidate);
if (fromHook) {
return fromHook;
}
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Scope source-map hooks to the owning runner.

Both lookupSourceMap() and applyCustomStackFormatter() walk every registered hook store globally. If two runners are alive at once, one runner's retrieveSourceMap/formatStack hook can remap or fully replace another runner's stack trace. formatStack is especially risky here because any non-undefined return short-circuits the chain.

Also applies to: 269-288, 476-479

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/module-runner/sourceMapInterceptor.ts` around lines 131 -
142, The current global iteration over globalHookStores in
lookupSourceMap()/applyCustomStackFormatter() lets hooks from other runners
(retrieveSourceMap/formatStack) intercept stacks; restrict iterations to the
hook store(s) owned by the current runner instead of walking all entries. Change
the loops that use globalHookStores.entries() (and the calls to
resolveHookSourceCandidate, sourceCandidates and resolveHookSourceMap) to filter
by the runner identity (e.g., entry.owner or compare entry.runnerId to the
current runner context) or accept a runnerId parameter and only examine the
matching store(s) so only hooks registered by that runner are consulted; apply
the same scoping change to the other occurrences you flagged (around the blocks
at 269-288 and 476-479).

Comment on lines +117 to +149
function rewriteModuleImportsForDataUrl(params: {
code: string;
filePath: string;
}) {
const moduleDir = path.dirname(params.filePath);
const originalFileUrl = pathToFileURL(params.filePath).href;
const importStatementRE =
/import\s+(?:[^'"`]*?\s+from\s+)?(['"])([^'"]+)\1\s*;?/g;
const rewrittenImports = params.code.replace(
importStatementRE,
(statement: string, quote: string, specifier: string) => {
if (specifier.endsWith('.css')) {
return '';
}

if (!specifier.startsWith('.')) {
return statement;
}

const absoluteFileUrl = pathToFileURL(
path.resolve(moduleDir, specifier)
).href;
return statement.replace(
`${quote}${specifier}${quote}`,
`${quote}${absoluteFileUrl}${quote}`
);
}
);

return rewrittenImports.replaceAll(
'createRequire(import.meta.url)',
`createRequire(${JSON.stringify(originalFileUrl)})`
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Node.js ESM, how are relative specifiers resolved from modules imported via data:URLs? Doexport ... from "./x.js"andimport("./x.js") resolve relative to the original file path?

💡 Result:

In Node.js ESM, all module specifiers inside a module are resolved relative to that module’s own URL (the “parentURL”), not relative to wherever the string that produced it originally came from.

So if a module is loaded from a data: URL, then:

  • export ... from "./x.js" does not resolve relative to the original file path.
  • import("./x.js") does not resolve relative to the original file path.
  • Instead, both attempt to resolve ./x.js against the data: URL as the base URL, and that fails because data: URLs are non-hierarchical, so there is no meaningful “directory” to resolve ./ from. Node documents this explicitly (example: data:text/javascript,import "./foo"; fails). [1]
  • In practice you’ll see errors like “Invalid URL” / “base scheme is not hierarchical” / ERR_UNSUPPORTED_RESOLVE_REQUEST when trying to do relative imports from a data: parent. [2]

This matches the platform URL-based module resolution model: resolving a relative specifier like ../foo requires a hierarchical base URL, and data: can’t serve as that base. [3]

Implication: If you need the data:-loaded module to import/re-export other modules, you must use absolute specifiers (e.g., file: / node: / fully qualified URLs), or avoid data: as the parent module URL. [1]

Sources

  1. Node.js ESM docs (example showing relative import from data: fails) — https://nodejs.org/download/release/v16.18.1/docs/api/esm.html [1]
  2. Node.js issue showing the runtime error pattern when importing from a data: parent — Importing module on filesystem from data URL throws TypeError: Invalid URL / ERR_UNSUPPORTED_RESOLVE_REQUEST: Invalid relative URL or base scheme is not hierarchical. nodejs/node#51956 [2]
  3. HTML / platform “resolve a module specifier” behavior with data: as base URL — https://html.spec.whatwg.org/multipage/webappapis.html [3]

🏁 Script executed:

cat -n packages/ssr/src/build-preview.ts | sed -n '100,200p'

Repository: farm-fe/farm

Length of output: 3618


Relative imports will fail when loaded from the data: URL fallback.

The rewriteModuleImportsForDataUrl regex only rewrites static import ... from statements, leaving export ... from './x.js' and import('./x.js') unresolved. When this code is loaded from a data: URL (line 171), these relative specifiers will fail to resolve because data: is non-hierarchical and cannot serve as a base for module resolution. Either rewrite all relative ESM specifiers using a parser, or load from a real file-backed URL instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ssr/src/build-preview.ts` around lines 117 - 149, The regex-only
approach in rewriteModuleImportsForDataUrl leaves relative specifiers in export
... from and dynamic import(...) forms, which will break when code is loaded
from a data: URL; update rewriteModuleImportsForDataUrl so all relative ESM
specifiers are rewritten to absolute file:// URLs: either (A) parse the module
(e.g., with a lightweight parser or acorn/estree) and replace specifiers for
ImportDeclaration, ExportNamedDeclaration/ExportAllDeclaration and
ImportExpression nodes, using pathToFileURL(path.resolve(moduleDir,
specifier)).href and keep the createRequire replacement using originalFileUrl,
or (B) avoid data: URLs entirely by writing the transformed code to a temporary
file and use its file:// URL as the module entry instead of the data: URL;
adjust the code paths that call rewriteModuleImportsForDataUrl (and the
createRequire(import.meta.url) replacement) accordingly.

Comment on lines +463 to +489
function emitRunnerPayload(
emit: (payload: RunnerHotPayload) => void,
result: SsrUpdateResult
) {
const changedModules = [
...result.changed,
...result.added,
...result.extraWatchResult.add
];

if (changedModules.length > 0) {
emit({
type: 'update',
updates: changedModules.map((path) => ({
type: 'js-update',
path,
acceptedPath: path,
timestamp: Date.now()
}))
});
return;
}

if (result.removed.length > 0) {
emit({ type: 'full-reload' });
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't ignore removals when other modules also changed.

If result.removed is non-empty together with changed/added, Line 473 returns an update payload and never emits the full reload already required for removed modules. Rename/delete events can then leave the runner with a stale module graph.

Proposed fix
 function emitRunnerPayload(
   emit: (payload: RunnerHotPayload) => void,
   result: SsrUpdateResult
 ) {
+  if (result.removed.length > 0) {
+    emit({ type: 'full-reload' });
+    return;
+  }
+
   const changedModules = [
     ...result.changed,
     ...result.added,
     ...result.extraWatchResult.add
   ];
@@
-  if (result.removed.length > 0) {
-    emit({ type: 'full-reload' });
-  }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function emitRunnerPayload(
emit: (payload: RunnerHotPayload) => void,
result: SsrUpdateResult
) {
const changedModules = [
...result.changed,
...result.added,
...result.extraWatchResult.add
];
if (changedModules.length > 0) {
emit({
type: 'update',
updates: changedModules.map((path) => ({
type: 'js-update',
path,
acceptedPath: path,
timestamp: Date.now()
}))
});
return;
}
if (result.removed.length > 0) {
emit({ type: 'full-reload' });
}
}
function emitRunnerPayload(
emit: (payload: RunnerHotPayload) => void,
result: SsrUpdateResult
) {
if (result.removed.length > 0) {
emit({ type: 'full-reload' });
return;
}
const changedModules = [
...result.changed,
...result.added,
...result.extraWatchResult.add
];
if (changedModules.length > 0) {
emit({
type: 'update',
updates: changedModules.map((path) => ({
type: 'js-update',
path,
acceptedPath: path,
timestamp: Date.now()
}))
});
return;
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ssr/src/dev-server.ts` around lines 463 - 489, The emitRunnerPayload
function currently returns an 'update' when changed/added exist and skips
'full-reload' even if result.removed is non-empty; change the control flow in
emitRunnerPayload so that if result.removed.length > 0 you emit a { type:
'full-reload' } (and return) before emitting any 'update' payloads, or otherwise
ensure the presence of removals forces a full reload; update logic around the
changedModules calculation and the early return to check result.removed first
(refer to emitRunnerPayload, changedModules, and result.removed).

Comment on lines +505 to +549
function watchServerCompilerWithSingleWatcher(params: {
watcher: SsrDevWatcherLike;
compiler: SsrDevCompilerLike;
onUpdateResult: (result: SsrUpdateResult) => void | Promise<void>;
}) {
let active = true;

const runUpdate = async (path: string, type: SsrUpdateType) => {
if (!active) {
return;
}

let result: SsrUpdateResult;
try {
result = await params.compiler.update([{ path, type }]);
} catch {
result = {
added: [],
changed: type === 'removed' ? [] : [path],
removed: type === 'removed' ? [path] : [],
extraWatchResult: {
add: []
}
};
}

if (!active) {
return;
}

await params.onUpdateResult(result);
};

params.watcher.on('add', async (file) => {
await runUpdate(normalizePath(file), 'added');
});

params.watcher.on('unlink', async (file) => {
await runUpdate(normalizePath(file), 'removed');
});

params.watcher.on('change', async (file) => {
const normalizedFile = normalizePath(file);
await runUpdate(normalizedFile, 'updated');
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Serialize watcher-driven server recompiles.

These watcher callbacks run independently, but each one recreates the compiler and mutates the shared invokeContext. On bursty file changes, an older rebuild can finish after a newer one and overwrite the latest compiler/root/publicPath state. Queue or coalesce updates before calling onUpdateResult.

Sketch of a fix
 function watchServerCompilerWithSingleWatcher(params: {
   watcher: SsrDevWatcherLike;
   compiler: SsrDevCompilerLike;
   onUpdateResult: (result: SsrUpdateResult) => void | Promise<void>;
 }) {
   let active = true;
+  let updateChain = Promise.resolve();
@@
-  params.watcher.on('add', async (file) => {
-    await runUpdate(normalizePath(file), 'added');
-  });
+  const enqueue = (file: string, type: SsrUpdateType) => {
+    updateChain = updateChain.then(() => runUpdate(normalizePath(file), type));
+    return updateChain.catch(() => undefined);
+  };
 
-  params.watcher.on('unlink', async (file) => {
-    await runUpdate(normalizePath(file), 'removed');
-  });
+  params.watcher.on('add', (file) => enqueue(file, 'added'));
+  params.watcher.on('unlink', (file) => enqueue(file, 'removed'));
+  params.watcher.on('change', (file) => enqueue(file, 'updated'));
-
-  params.watcher.on('change', async (file) => {
-    const normalizedFile = normalizePath(file);
-    await runUpdate(normalizedFile, 'updated');
-  });

Also applies to: 853-910

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ssr/src/dev-server.ts` around lines 505 - 549, The watcher handlers
in watchServerCompilerWithSingleWatcher (the params.watcher.on
'add'/'unlink'/'change' callbacks and runUpdate calling params.compiler.update
and params.onUpdateResult) can run concurrently and overwrite shared
invokeContext; serialize/coalesce these updates by introducing a single async
FIFO processor: queue incoming events (path+type) from the watcher callbacks,
have a single loop/task that dequeues events and runs params.compiler.update
sequentially, and coalesce bursty events (e.g., collapse multiple events for the
same path to the latest type) before calling params.onUpdateResult so only one
update runs at a time and only the latest state is applied to the shared
invokeContext. Ensure runUpdate is adapted to enqueue rather than directly
invoke params.compiler.update and that the processor respects the active flag
and drains/cleans the queue on shutdown.

…e ModuleRunner analysis

- Introduce SsrRuntime unified interface replacing SsrServer
- Add SsrRuntimeHooks for lifecycle observability (compile, invalidate, render)
- Implement asset manifest generation and injection for build outputs
- Support render() method for both dev/preview servers
- Consolidate SSR Toolkit example into single farm.config.ts
- Complete Farm vs Vite ModuleRunner analysis and P0/P1 implementation roadmap
- Add multi-runtime smoke tests (Node/Worker/Deno/Bun)
- Enhance error handling with typed SsrError codes

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

♻️ Duplicate comments (3)
packages/ssr/src/dev-server.ts (3)

482-507: ⚠️ Potential issue | 🟠 Major

Force full reload when any module is removed.

Line 492 returns after emitting update, so when removed + changed coexist, Line 505 is skipped and removals are ignored.

Proposed fix
 function emitRunnerPayload(
   emit: (payload: RunnerHotPayload) => void,
   result: SsrUpdateResult
 ) {
+  if (result.removed.length > 0) {
+    emit({ type: 'full-reload' });
+    return;
+  }
+
   const changedModules = [
     ...result.changed,
     ...result.added,
     ...result.extraWatchResult.add
   ];
@@
-  if (result.removed.length > 0) {
-    emit({ type: 'full-reload' });
-  }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ssr/src/dev-server.ts` around lines 482 - 507, The current
emitRunnerPayload returns immediately after emitting an 'update', which skips
the removal check, so removals are ignored when both changed and removed modules
exist; move or add the removed-modules check so that if result.removed.length >
0 you emit a { type: 'full-reload' } (and return) before/without being
short-circuited by the changedModules branch. Update the emitRunnerPayload
function to check result.removed first (or always evaluate removals after
computing changedModules) and ensure the full-reload is emitted whenever
result.removed is non-empty, referencing the emitRunnerPayload function,
changedModules variable, and result.removed.

616-640: ⚠️ Potential issue | 🟠 Major

Use function replacers when injecting appHtml into templates.

At Line 622 and Line 637, string replacement treats $ sequences in appHtml as replacement patterns, which can corrupt SSR output.

Proposed fix
   if (params.template.includes(params.placeholder)) {
-    return params.template.replace(params.placeholder, params.appHtml);
+    return params.template.replace(params.placeholder, () => params.appHtml);
   }
@@
   return params.template.replace(
     ROOT_CONTAINER_PATTERN,
-    `<div id="root">${params.appHtml}</div>`
+    () => `<div id="root">${params.appHtml}</div>`
   );
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ssr/src/dev-server.ts` around lines 616 - 640, The
injectAppHtmlToTemplate function currently uses string replacement with
replacement strings (in the two calls to params.template.replace) which treats $
sequences in params.appHtml as replacement patterns and can corrupt SSR output;
change both replacements to use replacer functions so the returned content is
used verbatim: replace params.placeholder with a function that returns
params.appHtml, and replace ROOT_CONTAINER_PATTERN with a function that returns
`<div id="root">${params.appHtml}</div>`; keep the same placeholder/error logic
and reference function injectAppHtmlToTemplate, params.template, params.appHtml,
DEFAULT_TEMPLATE_PLACEHOLDER, and ROOT_CONTAINER_PATTERN.

524-575: ⚠️ Potential issue | 🟠 Major

Serialize watcher-triggered compiler updates to avoid stale overwrite races.

The add/unlink/change handlers trigger runUpdate independently. Concurrent updates can finish out of order and overwrite shared runtime/compiler state.

Proposed fix
 function watchServerCompilerWithSingleWatcher(params: {
@@
 }) {
   let active = true;
+  let updateChain = Promise.resolve();
@@
-  params.watcher.on('add', async (file) => {
-    await runUpdate(normalizePath(file), 'added');
-  });
+  const enqueueUpdate = (file: string, type: SsrUpdateType) => {
+    const normalizedFile = normalizePath(file);
+    updateChain = updateChain
+      .then(() => runUpdate(normalizedFile, type))
+      .catch(() => undefined);
+    return updateChain;
+  };
 
-  params.watcher.on('unlink', async (file) => {
-    await runUpdate(normalizePath(file), 'removed');
-  });
+  params.watcher.on('add', (file) => {
+    void enqueueUpdate(file, 'added');
+  });
+  params.watcher.on('unlink', (file) => {
+    void enqueueUpdate(file, 'removed');
+  });
+  params.watcher.on('change', (file) => {
+    void enqueueUpdate(file, 'updated');
+  });
-
-  params.watcher.on('change', async (file) => {
-    const normalizedFile = normalizePath(file);
-    await runUpdate(normalizedFile, 'updated');
-  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ssr/src/dev-server.ts` around lines 524 - 575, The watcher handlers
in watchServerCompilerWithSingleWatcher currently call runUpdate concurrently
(via params.compiler.update), causing out-of-order finishes and stale
overwrites; change the implementation to serialize updates by introducing a
per-watcher update queue or pending promise chain so each runUpdate waits for
the previous to finish before starting, e.g., enqueue events from
params.watcher.on('add'|'change'|'unlink') and process them sequentially,
ensuring runUpdate (which calls params.compiler.update and then
params.onUpdateResult) is never running concurrently while respecting the active
flag and cancelling/clearing the queue when active becomes false.
🧹 Nitpick comments (7)
examples/ssr-toolkit/src/router.ts (1)

14-35: Avoid positional coupling between routeRecords and routes.

Using routes[0/1/2] makes headings fragile if routes is reordered or extended. Prefer key-based lookup to keep metadata mapping stable.

♻️ Proposed refactor
-import { notFoundRoute, routes } from './routes.mjs';
+import { getRouteByKey, notFoundRoute } from './routes.mjs';

 const routeRecords: RouteRecordRaw[] = [
   {
     path: '/',
     name: 'home',
     component: HomePage,
     meta: {
       key: 'home',
-      heading: routes[0].heading,
+      heading: getRouteByKey('home')?.heading ?? 'Home',
       status: 'ok'
     }
   },
   {
     path: '/about',
     name: 'about',
     component: () => import('./pages/AboutPage.vue'),
     meta: {
       key: 'about',
-      heading: routes[1].heading,
+      heading: getRouteByKey('about')?.heading ?? 'About',
       status: 'ok'
     }
   },
   {
     path: '/products',
     name: 'products',
     component: ProductsPage,
     meta: {
       key: 'products',
-      heading: routes[2].heading,
+      heading: getRouteByKey('products')?.heading ?? 'Products',
       status: 'ok'
     }
   },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/ssr-toolkit/src/router.ts` around lines 14 - 35, The route
definitions currently access headings via positional indexing into the routes
array (e.g., routes[0].heading, routes[1].heading, routes[2].heading), which is
fragile; change them to use a key-based lookup instead by implementing a small
helper (e.g., findRouteHeading or getHeadingByKey) that searches the routes
array for an object whose key matches the meta.key and returns its heading, then
replace all occurrences of routes[0/1/2].heading in the route records (the
entries for path '/', '/about', '/products' and their meta objects) with calls
to that helper; ensure the helper handles missing keys (fallback or undefined)
and reference the existing meta.key property on each route to perform the
lookup.
packages/ssr/src/command.ts (1)

59-61: Use destructured variable instead of re-accessing property.

Line 61 accesses options.start but the value was already destructured to _start on line 59. While functionally equivalent, using the destructured variable is more consistent.

♻️ Proposed fix
   const { start: _start, ...runtimeOptions } =
     options as SsrRunServerCommandOptions;
-  const shouldStart = options.start ?? true;
+  const shouldStart = _start ?? true;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ssr/src/command.ts` around lines 59 - 61, The code destructures
start into _start from options but then re-reads options.start; change the
shouldStart assignment to use the destructured _start (i.e., set shouldStart
using _start ?? true) so you consistently use the extracted variable from
SsrRunServerCommandOptions rather than re-accessing options.start; update the
reference in the shouldStart declaration to _start.
packages/ssr/src/config-resolver.ts (2)

17-19: Unused command parameter.

The command parameter is declared but never used in the function body. If this is intentional for future use or API consistency, consider prefixing with underscore (_command) to signal intent.

♻️ Proposed fix
 export function resolveSsrConfigForCommand<T extends SsrConfig>(
   config: T,
-  command: 'dev' | 'preview' | 'build'
+  _command: 'dev' | 'preview' | 'build'
 ): {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ssr/src/config-resolver.ts` around lines 17 - 19, The function
resolveSsrConfigForCommand declares a command parameter that's unused; either
use it or mark intent by renaming to _command to avoid lint warnings and
communicate it's intentionally unused. Update the function signature
(resolveSsrConfigForCommand<T extends SsrConfig>(config: T, _command: 'dev' |
'preview' | 'build')) or incorporate the command into the function logic where
appropriate, ensuring references to the parameter inside the body match the new
name if renamed.

39-49: Simplify redundant server resolution logic.

Both branches (config.server truthy and config.$server truthy) produce identical merge results. The conditional can be simplified.

♻️ Proposed fix
-  const resolvedServer = config.server
-    ? {
-        ...baseServer,
-        ...overrideServer
-      }
-    : config.$server
-      ? {
-          ...baseServer,
-          ...overrideServer
-        }
-      : undefined;
+  const resolvedServer =
+    config.server || config.$server
+      ? {
+          ...baseServer,
+          ...overrideServer
+        }
+      : undefined;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ssr/src/config-resolver.ts` around lines 39 - 49, The server
resolution duplicates the same merge for both config.server and config.$server;
replace the ternary with a single check that tests either property and returns
the merged object (e.g., use (config.server || config.$server) to decide) so
resolvedServer is either { ...baseServer, ...overrideServer } when either exists
or undefined otherwise; update the expression that defines resolvedServer
accordingly (references: resolvedServer, config.server, config.$server,
baseServer, overrideServer).
packages/ssr/src/runtime.ts (1)

50-75: Inconsistent server config handling between preview and dev paths.

In the preview path (Lines 58-61), server is always created with a fallback to empty object (resolved.server ?? {}), ensuring it's always present. In the dev path (Lines 84-91), server is conditionally spread only when resolved.server exists.

This asymmetry could lead to different behavior depending on the command. If preview requires a server config to always exist, consider documenting this requirement or aligning the dev path behavior.

Also applies to: 77-104

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ssr/src/runtime.ts` around lines 50 - 75, The preview path always
ensures a server config exists by using ...(resolved.server ?? {}) and
defaulting server.mode to resolved.server?.mode ?? mode; make the dev path build
its options the same way: when constructing the dev options (the block analogous
to previewOptions in the dev command), spread ...(resolved.server ?? {}) into
server and set server.mode to resolved.server?.mode ?? mode so both commands
behave consistently; update the dev options construction where
resolveSsrConfigForCommand(resolved, ...) is used to match the previewOptions
pattern.
packages/ssr/src/server.ts (1)

62-100: Consider extracting duplicated command/mode resolution.

Both toDevServerOptions and toPreviewServerOptions call resolveCommand and resolveMode identically. Consider extracting common resolution to reduce duplication.

♻️ Proposed refactor
+function resolveOptionsBase(options: SsrServerOptions) {
+  const command = resolveCommand(options);
+  const mode = resolveMode(options, command);
+  const resolved = resolveSsrConfigForCommand(options, command);
+  return { command, mode, resolved };
+}
+
 function toDevServerOptions(options: SsrServerOptions): SsrDevServerOptions {
-  const command = resolveCommand(options);
-  const mode = resolveMode(options, command);
-  const resolved = resolveSsrConfigForCommand(options, command);
+  const { mode, resolved } = resolveOptionsBase(options);

   return {
     ...resolved,
     client: {
       ...resolved.client,
       mode: resolved.client.mode ?? mode
     },
     ...(resolved.server
       ? {
           server: {
             ...resolved.server,
             mode: resolved.server.mode ?? mode
           }
         }
       : {})
   };
 }

 function toPreviewServerOptions(options: SsrServerOptions): SsrPreviewOptions {
-  const command = resolveCommand(options);
-  const mode = resolveMode(options, command);
-  const resolved = resolveSsrConfigForCommand(options, command);
+  const { mode, resolved } = resolveOptionsBase(options);

   return {
     ...resolved,
     client: {
       ...resolved.client,
       mode: resolved.client.mode ?? mode
     },
     server: {
       ...(resolved.server ?? {}),
       mode: resolved.server?.mode ?? mode
     }
   };
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ssr/src/server.ts` around lines 62 - 100, Extract the repeated
resolveCommand/resolveMode/resolveSsrConfigForCommand logic into a small helper
(e.g., getResolvedSsrOptions(options: SsrServerOptions) returning { command,
mode, resolved }) and call it from both toDevServerOptions and
toPreviewServerOptions; replace the three duplicated lines in each function (the
resolveCommand, resolveMode, and resolveSsrConfigForCommand calls) with a single
call to that helper and use its returned command/mode/resolved values to build
the returned object, keeping existing behavior for client.mode and server.mode
defaults.
packages/ssr/test/build-preview.spec.ts (1)

831-872: Integration tests use real filesystem - ensure cleanup on failure.

The CSS interop tests correctly use try/finally to clean up temp directories, which is good practice. However, if mkdtemp succeeds but writeFile fails, the directory may be left behind.

Consider wrapping the entire test body in the try block:

♻️ Proposed improvement
   it('imports server module with css side-effect imports via fallback interop', async () => {
     const tmpRoot = await mkdtemp(path.join(tmpdir(), 'farm-ssr-css-interop-'));
-    const entryPath = path.join(tmpRoot, 'entry.mjs');
-    const depPath = path.join(tmpRoot, 'dep.mjs');
-    const cssPath = path.join(tmpRoot, 'style.css');

     try {
+      const entryPath = path.join(tmpRoot, 'entry.mjs');
+      const depPath = path.join(tmpRoot, 'dep.mjs');
+      const cssPath = path.join(tmpRoot, 'style.css');
+
       await writeFile(depPath, "export const value = 'ok';\n", 'utf-8');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ssr/test/build-preview.spec.ts` around lines 831 - 872, Move the
temporary directory creation inside the try block (or guard the finally cleanup
with a check that tmpRoot is truthy) so we always attempt removal only if
mkdtemp succeeded; specifically, in these tests reference the tmpRoot variable
created by mkdtemp and ensure the try/finally wraps the mkdtemp + writeFile +
importModuleWithCssInterop sequence (or change finally to call rm only when
tmpRoot is defined) so rm(tmpRoot, { recursive: true, force: true }) never runs
on an undefined path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/cli/src/ssr.ts`:
- Around line 258-264: buildFarmConfig currently copies options.input into every
config which causes --input to overwrite the server render entry; update the
logic so that when creating the server bundle config (and in
resolveSsrRunOptions when producing server run options) you do NOT apply
options.input to the server side. Concretely, ensure buildFarmConfig and
resolveSsrRunOptions only attach options.input to the client config/path (or set
input to undefined/omit it) for the server config, and update the sections
referenced (around buildFarmConfig, the resolveSsrRunOptions usage at lines
~315-320 and ~357-370) so the server bundle preserves its original entry without
being replaced by --input.
- Around line 45-52: The resolveConfigFile helper currently forces a fallback
path (params.fallback) which causes explicit injection of farm.config.client.ts
/ farm.config.server.ts even when no flags are passed; change resolveConfigFile
to allow returning undefined when neither dedicatedConfigFile nor
sharedConfigFile are provided (make fallback optional: fallback?: string and
return params.dedicatedConfigFile ?? params.sharedConfigFile ?? undefined) and
update all call sites that currently pass a hardcoded fallback to only pass it
when the user explicitly supplied a config flag, so the normal farm.config.*
discovery (including the examples/ssr-toolkit shared config) can run; update
callers referenced around the other occurrences noted (lines ~258-264, ~301-304,
~343-352) to stop forcing the fallback.
- Around line 33-39: DEFAULT_DEV_ENTRY_CANDIDATES are currently absolute (start
with '/') which causes toAbsolutePath(path) to treat them as already-absolute
and makes existsSync probe the filesystem root; change the entries to relative
forms (e.g., './src/entry-server.mjs', './src/entry-server.ts',
'./src/entry-server.js', './src/entry-server.tsx', './src/entry-server.jsx')
wherever DEFAULT_DEV_ENTRY_CANDIDATES is defined so path.isAbsolute() returns
false and toAbsolutePath(root, candidate) will join with the project root for
existsSync checks; also ensure the code that returns the chosen entry (the value
produced from the candidate after checking existsSync) returns the
project-relative candidate (strip the './' prefix if necessary) rather than the
absolute path returned by toAbsolutePath — update uses around
DEFAULT_DEV_ENTRY_CANDIDATES and the selection logic that calls toAbsolutePath
and existsSync accordingly.

In `@packages/ssr/src/dev-server.ts`:
- Around line 74-78: The middleware type and runner must handle async
middleware: change the SsrMiddleware typedef to return void | Promise<void>
(i.e., export type SsrMiddleware = (... ) => void | Promise<void>) and update
the middleware runner in createMiddlewareServer to capture the middleware return
value, detect a Promise (e.g., result && typeof result.then === 'function'), and
attach a .catch(err => next?.(err) || handleError(err)) so rejected Promises
invoke the existing error path instead of becoming unhandled rejections; apply
the same pattern to the other middleware invocation site mentioned in the
comment.

In `@packages/ssr/src/manifest.ts`:
- Around line 126-136: The asset paths in the loops that push into
headLinks/bodyScripts (the iterations over params.assets.css,
params.assets.preload, and params.assets.scripts) are interpolated directly into
HTML attributes and must be sanitized or validated to prevent XSS; update the
code that constructs these links (the headLinks.push and bodyScripts.push sites)
to either HTML-escape special characters in href/src (at minimum &, <, >, ", and
') or validate each path against a strict whitelist/regex (e.g., must start with
'/' or 'https://' and contain no quotes or control characters) and skip or error
on invalid values before interpolation. Ensure the chosen escape/validation
function is applied consistently to href in the css/preload loops and to src in
the scripts loop.

---

Duplicate comments:
In `@packages/ssr/src/dev-server.ts`:
- Around line 482-507: The current emitRunnerPayload returns immediately after
emitting an 'update', which skips the removal check, so removals are ignored
when both changed and removed modules exist; move or add the removed-modules
check so that if result.removed.length > 0 you emit a { type: 'full-reload' }
(and return) before/without being short-circuited by the changedModules branch.
Update the emitRunnerPayload function to check result.removed first (or always
evaluate removals after computing changedModules) and ensure the full-reload is
emitted whenever result.removed is non-empty, referencing the emitRunnerPayload
function, changedModules variable, and result.removed.
- Around line 616-640: The injectAppHtmlToTemplate function currently uses
string replacement with replacement strings (in the two calls to
params.template.replace) which treats $ sequences in params.appHtml as
replacement patterns and can corrupt SSR output; change both replacements to use
replacer functions so the returned content is used verbatim: replace
params.placeholder with a function that returns params.appHtml, and replace
ROOT_CONTAINER_PATTERN with a function that returns `<div
id="root">${params.appHtml}</div>`; keep the same placeholder/error logic and
reference function injectAppHtmlToTemplate, params.template, params.appHtml,
DEFAULT_TEMPLATE_PLACEHOLDER, and ROOT_CONTAINER_PATTERN.
- Around line 524-575: The watcher handlers in
watchServerCompilerWithSingleWatcher currently call runUpdate concurrently (via
params.compiler.update), causing out-of-order finishes and stale overwrites;
change the implementation to serialize updates by introducing a per-watcher
update queue or pending promise chain so each runUpdate waits for the previous
to finish before starting, e.g., enqueue events from
params.watcher.on('add'|'change'|'unlink') and process them sequentially,
ensuring runUpdate (which calls params.compiler.update and then
params.onUpdateResult) is never running concurrently while respecting the active
flag and cancelling/clearing the queue when active becomes false.

---

Nitpick comments:
In `@examples/ssr-toolkit/src/router.ts`:
- Around line 14-35: The route definitions currently access headings via
positional indexing into the routes array (e.g., routes[0].heading,
routes[1].heading, routes[2].heading), which is fragile; change them to use a
key-based lookup instead by implementing a small helper (e.g., findRouteHeading
or getHeadingByKey) that searches the routes array for an object whose key
matches the meta.key and returns its heading, then replace all occurrences of
routes[0/1/2].heading in the route records (the entries for path '/', '/about',
'/products' and their meta objects) with calls to that helper; ensure the helper
handles missing keys (fallback or undefined) and reference the existing meta.key
property on each route to perform the lookup.

In `@packages/ssr/src/command.ts`:
- Around line 59-61: The code destructures start into _start from options but
then re-reads options.start; change the shouldStart assignment to use the
destructured _start (i.e., set shouldStart using _start ?? true) so you
consistently use the extracted variable from SsrRunServerCommandOptions rather
than re-accessing options.start; update the reference in the shouldStart
declaration to _start.

In `@packages/ssr/src/config-resolver.ts`:
- Around line 17-19: The function resolveSsrConfigForCommand declares a command
parameter that's unused; either use it or mark intent by renaming to _command to
avoid lint warnings and communicate it's intentionally unused. Update the
function signature (resolveSsrConfigForCommand<T extends SsrConfig>(config: T,
_command: 'dev' | 'preview' | 'build')) or incorporate the command into the
function logic where appropriate, ensuring references to the parameter inside
the body match the new name if renamed.
- Around line 39-49: The server resolution duplicates the same merge for both
config.server and config.$server; replace the ternary with a single check that
tests either property and returns the merged object (e.g., use (config.server ||
config.$server) to decide) so resolvedServer is either { ...baseServer,
...overrideServer } when either exists or undefined otherwise; update the
expression that defines resolvedServer accordingly (references: resolvedServer,
config.server, config.$server, baseServer, overrideServer).

In `@packages/ssr/src/runtime.ts`:
- Around line 50-75: The preview path always ensures a server config exists by
using ...(resolved.server ?? {}) and defaulting server.mode to
resolved.server?.mode ?? mode; make the dev path build its options the same way:
when constructing the dev options (the block analogous to previewOptions in the
dev command), spread ...(resolved.server ?? {}) into server and set server.mode
to resolved.server?.mode ?? mode so both commands behave consistently; update
the dev options construction where resolveSsrConfigForCommand(resolved, ...) is
used to match the previewOptions pattern.

In `@packages/ssr/src/server.ts`:
- Around line 62-100: Extract the repeated
resolveCommand/resolveMode/resolveSsrConfigForCommand logic into a small helper
(e.g., getResolvedSsrOptions(options: SsrServerOptions) returning { command,
mode, resolved }) and call it from both toDevServerOptions and
toPreviewServerOptions; replace the three duplicated lines in each function (the
resolveCommand, resolveMode, and resolveSsrConfigForCommand calls) with a single
call to that helper and use its returned command/mode/resolved values to build
the returned object, keeping existing behavior for client.mode and server.mode
defaults.

In `@packages/ssr/test/build-preview.spec.ts`:
- Around line 831-872: Move the temporary directory creation inside the try
block (or guard the finally cleanup with a check that tmpRoot is truthy) so we
always attempt removal only if mkdtemp succeeded; specifically, in these tests
reference the tmpRoot variable created by mkdtemp and ensure the try/finally
wraps the mkdtemp + writeFile + importModuleWithCssInterop sequence (or change
finally to call rm only when tmpRoot is defined) so rm(tmpRoot, { recursive:
true, force: true }) never runs on an undefined path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 720245a1-c7ac-4328-88b3-0fdf70b73ac9

📥 Commits

Reviewing files that changed from the base of the PR and between f6dfeeb and 1f8b437.

📒 Files selected for processing (30)
  • examples/ssr-toolkit/README.md
  • examples/ssr-toolkit/farm.config.ts
  • examples/ssr-toolkit/package.json
  • examples/ssr-toolkit/server.mjs
  • examples/ssr-toolkit/server/ssr-options.mjs
  • examples/ssr-toolkit/src/pages/AboutPage.vue
  • examples/ssr-toolkit/src/pages/HomePage.vue
  • examples/ssr-toolkit/src/pages/ProductsPage.vue
  • examples/ssr-toolkit/src/router.ts
  • examples/ssr-toolkit/src/routes.mjs
  • examples/ssr-toolkit/tsconfig.json
  • packages/cli/src/ssr.spec.ts
  • packages/cli/src/ssr.ts
  • packages/ssr/src/build-preview.ts
  • packages/ssr/src/command.ts
  • packages/ssr/src/config-resolver.ts
  • packages/ssr/src/dev-server.ts
  • packages/ssr/src/errors.ts
  • packages/ssr/src/index.ts
  • packages/ssr/src/manifest.ts
  • packages/ssr/src/runtime-hooks.ts
  • packages/ssr/src/runtime-types.ts
  • packages/ssr/src/runtime.ts
  • packages/ssr/src/server.ts
  • packages/ssr/test/build-preview.spec.ts
  • packages/ssr/test/command.spec.ts
  • packages/ssr/test/dev-server.spec.ts
  • packages/ssr/test/manifest.spec.ts
  • packages/ssr/test/ssr-toolkit-example.spec.ts
  • vitest.config.ts
✅ Files skipped from review due to trivial changes (9)
  • examples/ssr-toolkit/tsconfig.json
  • vitest.config.ts
  • examples/ssr-toolkit/src/pages/ProductsPage.vue
  • examples/ssr-toolkit/package.json
  • examples/ssr-toolkit/src/pages/AboutPage.vue
  • packages/ssr/src/runtime-types.ts
  • packages/ssr/test/manifest.spec.ts
  • examples/ssr-toolkit/src/pages/HomePage.vue
  • examples/ssr-toolkit/server.mjs
🚧 Files skipped from review as they are similar to previous changes (7)
  • packages/ssr/test/command.spec.ts
  • packages/cli/src/ssr.spec.ts
  • packages/ssr/test/ssr-toolkit-example.spec.ts
  • packages/ssr/test/dev-server.spec.ts
  • examples/ssr-toolkit/README.md
  • packages/ssr/src/index.ts
  • packages/ssr/src/build-preview.ts

Comment thread packages/cli/src/ssr.ts
Comment on lines +33 to +39
const DEFAULT_DEV_ENTRY_CANDIDATES = [
'/src/entry-server.mjs',
'/src/entry-server.ts',
'/src/entry-server.js',
'/src/entry-server.tsx',
'/src/entry-server.jsx'
] as const;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
node - <<'NODE'
const path = require('node:path');
for (const candidate of ['/src/entry-server.ts', './src/entry-server.ts']) {
  console.log(JSON.stringify({
    candidate,
    isAbsolute: path.isAbsolute(candidate)
  }));
}
NODE

Repository: farm-fe/farm

Length of output: 167


🏁 Script executed:

wc -l packages/cli/src/ssr.ts

Repository: farm-fe/farm

Length of output: 83


🏁 Script executed:

cat -n packages/cli/src/ssr.ts | head -200

Repository: farm-fe/farm

Length of output: 6038


🏁 Script executed:

# Check the toAbsolutePath helper around lines 124-129
cat -n packages/cli/src/ssr.ts | sed -n '115,140p'

Repository: farm-fe/farm

Length of output: 721


🏁 Script executed:

# Check resolveEntry usage around line 155 and resolveBuildEntry around line 176
cat -n packages/cli/src/ssr.ts | sed -n '145,190p'

Repository: farm-fe/farm

Length of output: 1351


🏁 Script executed:

# Look for where these resolved entries are actually used
rg -n "resolveEntry|resolveBuildEntry" packages/cli/src/ssr.ts -A 5 -B 2

Repository: farm-fe/farm

Length of output: 842


🏁 Script executed:

# Check where the returned entry is used in the config
cat -n packages/cli/src/ssr.ts | sed -n '210,230p'

Repository: farm-fe/farm

Length of output: 672


🏁 Script executed:

# Also check line 389 area where resolveEntry result is used
cat -n packages/cli/src/ssr.ts | sed -n '380,400p'

Repository: farm-fe/farm

Length of output: 601


Fix the default entry candidates; they currently resolve from filesystem root.

DEFAULT_DEV_ENTRY_CANDIDATES starts with /, and toAbsolutePath() checks path.isAbsolute() before stripping that prefix. On Node.js, /src/entry-server.ts is absolute, so toAbsolutePath() returns it unchanged. The existsSync() checks on lines 155 and 176 then probe /src/... on the filesystem root instead of <root>/src/.... The returned entry value is also incorrect—it remains an absolute path like /src/entry-server.ts instead of a project-relative path. This breaks SSR entry discovery and build configuration.

Change the candidates to relative paths (e.g., ./src/entry-server.mjs) so path.isAbsolute() returns false and toAbsolutePath() properly joins them with the project root.

Proposed fix
 const DEFAULT_DEV_ENTRY_CANDIDATES = [
-  '/src/entry-server.mjs',
-  '/src/entry-server.ts',
-  '/src/entry-server.js',
-  '/src/entry-server.tsx',
-  '/src/entry-server.jsx'
+  './src/entry-server.mjs',
+  './src/entry-server.ts',
+  './src/entry-server.js',
+  './src/entry-server.tsx',
+  './src/entry-server.jsx'
 ] as const;

Also affects: 124–129, 154–160, 175–181

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const DEFAULT_DEV_ENTRY_CANDIDATES = [
'/src/entry-server.mjs',
'/src/entry-server.ts',
'/src/entry-server.js',
'/src/entry-server.tsx',
'/src/entry-server.jsx'
] as const;
const DEFAULT_DEV_ENTRY_CANDIDATES = [
'./src/entry-server.mjs',
'./src/entry-server.ts',
'./src/entry-server.js',
'./src/entry-server.tsx',
'./src/entry-server.jsx'
] as const;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/ssr.ts` around lines 33 - 39, DEFAULT_DEV_ENTRY_CANDIDATES
are currently absolute (start with '/') which causes toAbsolutePath(path) to
treat them as already-absolute and makes existsSync probe the filesystem root;
change the entries to relative forms (e.g., './src/entry-server.mjs',
'./src/entry-server.ts', './src/entry-server.js', './src/entry-server.tsx',
'./src/entry-server.jsx') wherever DEFAULT_DEV_ENTRY_CANDIDATES is defined so
path.isAbsolute() returns false and toAbsolutePath(root, candidate) will join
with the project root for existsSync checks; also ensure the code that returns
the chosen entry (the value produced from the candidate after checking
existsSync) returns the project-relative candidate (strip the './' prefix if
necessary) rather than the absolute path returned by toAbsolutePath — update
uses around DEFAULT_DEV_ENTRY_CANDIDATES and the selection logic that calls
toAbsolutePath and existsSync accordingly.

Comment thread packages/cli/src/ssr.ts
Comment on lines +45 to +52
function resolveConfigFile(params: {
sharedConfigFile?: string;
dedicatedConfigFile?: string;
fallback: string;
}) {
return (
params.dedicatedConfigFile ?? params.sharedConfigFile ?? params.fallback
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Let SSR CLI fall back to shared/default config discovery.

When no config flag is passed, this still injects farm.config.client.ts and farm.config.server.ts into both resolved configs. That bypasses the normal farm.config.* lookup and misses the new shared example config in examples/ssr-toolkit/farm.config.ts, so farm ssr ... won't pick up the Vue/Less setup out of the box.

🧭 Proposed fix
 function resolveConfigFile(params: {
   sharedConfigFile?: string;
   dedicatedConfigFile?: string;
-  fallback: string;
 }) {
-  return (
-    params.dedicatedConfigFile ?? params.sharedConfigFile ?? params.fallback
-  );
+  return params.dedicatedConfigFile ?? params.sharedConfigFile;
 }
@@
 function buildFarmConfig(params: {
   root: string;
   mode?: string;
   clearScreen?: boolean;
-  configFile: string;
+  configFile?: string;
   options: GlobalCliOptions & CliSsrOptions;
 }) {
@@
   return {
     root: params.root,
-    configFile: params.configFile,
+    ...(params.configFile ? { configFile: params.configFile } : {}),
     mode: params.mode,
     clearScreen: params.clearScreen,

Also applies to: 258-264, 301-304, 343-352

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/ssr.ts` around lines 45 - 52, The resolveConfigFile helper
currently forces a fallback path (params.fallback) which causes explicit
injection of farm.config.client.ts / farm.config.server.ts even when no flags
are passed; change resolveConfigFile to allow returning undefined when neither
dedicatedConfigFile nor sharedConfigFile are provided (make fallback optional:
fallback?: string and return params.dedicatedConfigFile ??
params.sharedConfigFile ?? undefined) and update all call sites that currently
pass a hardcoded fallback to only pass it when the user explicitly supplied a
config flag, so the normal farm.config.* discovery (including the
examples/ssr-toolkit shared config) can run; update callers referenced around
the other occurrences noted (lines ~258-264, ~301-304, ~343-352) to stop forcing
the fallback.

Comment thread packages/cli/src/ssr.ts
Comment on lines +258 to +264
function buildFarmConfig(params: {
root: string;
mode?: string;
clearScreen?: boolean;
configFile: string;
options: GlobalCliOptions & CliSsrOptions;
}) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't apply --input to the server bundle.

buildFarmConfig() copies options.input into every config it builds, and resolveSsrRunOptions() reuses it for both client and server. For farm ssr build, the CLI exposes --input but not --entry, so passing --input silently replaces the server render entry as well.

🛠️ Proposed fix
 function buildFarmConfig(params: {
   root: string;
   mode?: string;
   clearScreen?: boolean;
   configFile: string;
   options: GlobalCliOptions & CliSsrOptions;
+  kind: 'client' | 'server';
 }) {
@@
-            ...(params.options.input
+            ...(params.kind === 'client' && params.options.input
               ? {
                   input: {
                     index: params.options.input
@@
     client: buildFarmConfig({
       root,
       mode: params.options.mode,
       clearScreen: params.options.clearScreen,
       configFile: clientConfigFile,
-      options: params.options
+      options: params.options,
+      kind: 'client'
     }),
     server: buildFarmConfig({
       root,
       mode: params.options.mode,
       clearScreen: params.options.clearScreen,
       configFile: serverConfigFile,
-      options: params.options
+      options: params.options,
+      kind: 'server'
     }),

Also applies to: 315-320, 357-370

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/ssr.ts` around lines 258 - 264, buildFarmConfig currently
copies options.input into every config which causes --input to overwrite the
server render entry; update the logic so that when creating the server bundle
config (and in resolveSsrRunOptions when producing server run options) you do
NOT apply options.input to the server side. Concretely, ensure buildFarmConfig
and resolveSsrRunOptions only attach options.input to the client config/path (or
set input to undefined/omit it) for the server config, and update the sections
referenced (around buildFarmConfig, the resolveSsrRunOptions usage at lines
~315-320 and ~357-370) so the server bundle preserves its original entry without
being replaced by --input.

Comment on lines +74 to +78
export type SsrMiddleware = (
req: IncomingMessage,
res: ServerResponse,
next?: (error?: unknown) => void
) => void;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let me check the file exists and read the specific lines
wc -l packages/ssr/src/dev-server.ts

Repository: farm-fe/farm

Length of output: 91


🏁 Script executed:

# Read the middleware type definition at lines 74-78
sed -n '70,85p' packages/ssr/src/dev-server.ts

Repository: farm-fe/farm

Length of output: 397


🏁 Script executed:

# Read the middleware invocation code at lines 245-275
sed -n '240,280p' packages/ssr/src/dev-server.ts

Repository: farm-fe/farm

Length of output: 785


🏁 Script executed:

# Search for middleware implementations to see if they use Promises
rg "SsrMiddleware|middleware.*async|middleware.*Promise" packages/ssr/src/dev-server.ts -A 3 -B 1

Repository: farm-fe/farm

Length of output: 1094


🏁 Script executed:

# Search for middleware implementations and async usage
rg "middlewares\.use|\.use\(" packages/ssr -A 5 | head -50

Repository: farm-fe/farm

Length of output: 2450


🏁 Script executed:

# Search for async middleware implementations
rg "async.*\(" packages/ssr/src/dev-server.ts -B 2 -A 3

Repository: farm-fe/farm

Length of output: 2903


🏁 Script executed:

# Check if any middleware in the project uses async/Promise patterns
rg "middleware.*=.*async|SsrMiddleware.*async" packages/ssr -A 3

Repository: farm-fe/farm

Length of output: 38


Handle async middleware rejections in the middleware runner.

createMiddlewareServer only catches synchronous exceptions. The SsrMiddleware type declares return void, but TypeScript permits assigning async functions to this type, which would cause Promise rejections to bypass the error handler and surface as unhandled rejections.

Update the type definition to explicitly allow Promise<void> returns and add Promise rejection handling:

Proposed fix
-export type SsrMiddleware = (
+export type SsrMiddleware = (
   req: IncomingMessage,
   res: ServerResponse,
   next?: (error?: unknown) => void
-) => void;
+) => void | Promise<void>;
@@
       try {
-        middleware(req, res, run);
+        const maybePromise = middleware(req, res, run);
+        if (
+          maybePromise &&
+          typeof (maybePromise as Promise<void>).then === 'function'
+        ) {
+          void (maybePromise as Promise<void>).catch((err) => run(err));
+        }
       } catch (err) {
         run(err);
       }

Also applies to: 245-275

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ssr/src/dev-server.ts` around lines 74 - 78, The middleware type and
runner must handle async middleware: change the SsrMiddleware typedef to return
void | Promise<void> (i.e., export type SsrMiddleware = (... ) => void |
Promise<void>) and update the middleware runner in createMiddlewareServer to
capture the middleware return value, detect a Promise (e.g., result && typeof
result.then === 'function'), and attach a .catch(err => next?.(err) ||
handleError(err)) so rejected Promises invoke the existing error path instead of
becoming unhandled rejections; apply the same pattern to the other middleware
invocation site mentioned in the comment.

Comment on lines +126 to +136
for (const href of params.assets.css) {
headLinks.push(`<link rel="stylesheet" href="${href}">`);
}

for (const href of params.assets.preload) {
headLinks.push(`<link rel="modulepreload" href="${href}">`);
}

for (const src of params.assets.scripts) {
bodyScripts.push(`<script type="module" src="${src}"></script>`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Unsanitized asset paths in HTML injection.

Asset paths from the manifest are directly interpolated into HTML attributes without escaping. If an attacker can influence the manifest (e.g., through a compromised build pipeline), this could lead to XSS via paths like " onload="alert(1).

Consider escaping special HTML characters in href and src values, or validate that paths match expected patterns (e.g., start with / and contain no quotes).

🛡️ Proposed fix with basic escaping
+function escapeHtmlAttr(value: string): string {
+  return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
+}
+
 export function injectAssetsIntoHtml(params: {
   html: string;
   assets: SsrRuntimeAssets;
 }): string {
   const headLinks: string[] = [];
   const bodyScripts: string[] = [];

   for (const href of params.assets.css) {
-    headLinks.push(`<link rel="stylesheet" href="${href}">`);
+    headLinks.push(`<link rel="stylesheet" href="${escapeHtmlAttr(href)}">`);
   }

   for (const href of params.assets.preload) {
-    headLinks.push(`<link rel="modulepreload" href="${href}">`);
+    headLinks.push(`<link rel="modulepreload" href="${escapeHtmlAttr(href)}">`);
   }

   for (const src of params.assets.scripts) {
-    bodyScripts.push(`<script type="module" src="${src}"></script>`);
+    bodyScripts.push(`<script type="module" src="${escapeHtmlAttr(src)}"></script>`);
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (const href of params.assets.css) {
headLinks.push(`<link rel="stylesheet" href="${href}">`);
}
for (const href of params.assets.preload) {
headLinks.push(`<link rel="modulepreload" href="${href}">`);
}
for (const src of params.assets.scripts) {
bodyScripts.push(`<script type="module" src="${src}"></script>`);
}
function escapeHtmlAttr(value: string): string {
return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
}
export function injectAssetsIntoHtml(params: {
html: string;
assets: SsrRuntimeAssets;
}): string {
const headLinks: string[] = [];
const bodyScripts: string[] = [];
for (const href of params.assets.css) {
headLinks.push(`<link rel="stylesheet" href="${escapeHtmlAttr(href)}">`);
}
for (const href of params.assets.preload) {
headLinks.push(`<link rel="modulepreload" href="${escapeHtmlAttr(href)}">`);
}
for (const src of params.assets.scripts) {
bodyScripts.push(`<script type="module" src="${escapeHtmlAttr(src)}"></script>`);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ssr/src/manifest.ts` around lines 126 - 136, The asset paths in the
loops that push into headLinks/bodyScripts (the iterations over
params.assets.css, params.assets.preload, and params.assets.scripts) are
interpolated directly into HTML attributes and must be sanitized or validated to
prevent XSS; update the code that constructs these links (the headLinks.push and
bodyScripts.push sites) to either HTML-escape special characters in href/src (at
minimum &, <, >, ", and ') or validate each path against a strict
whitelist/regex (e.g., must start with '/' or 'https://' and contain no quotes
or control characters) and skip or error on invalid values before interpolation.
Ensure the chosen escape/validation function is applied consistently to href in
the css/preload loops and to src in the scripts loop.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant