Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat(stream): webpack plugin + bundler-agnostic factory#751

Merged
harlan-zw merged 9 commits into
mainfrom
fix/missing-stream-plugins
Apr 24, 2026
Merged

feat(stream): webpack plugin + bundler-agnostic factory#751
harlan-zw merged 9 commits into
mainfrom
fix/missing-stream-plugins

Conversation

@harlan-zw

@harlan-zw harlan-zw commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator

πŸ”— Linked issue

N/A β€” surfaced while filling in the missing webpack story for the streaming plugin.

❓ Type of change

  • πŸ“– Documentation
  • 🐞 Bug fix
  • πŸ‘Œ Enhancement
  • ✨ New feature
  • ⚠️ Breaking change

πŸ“š Description

Anyone on webpack had no way to wire the streaming bootstrap; only unhead/stream/vite existed. Extracted the plugin into a bundler-agnostic unplugin factory and added webpack alongside the existing vite entry.

Framework packages (@unhead/{react,solid-js,svelte,vue}) gain a matching ./webpack entry so Unhead({ streaming: true }) works on webpack.

For other bundlers (rspack, rollup, esbuild, rolldown), the framework packages still expose the streaming plugin as a raw unplugin instance via the existing @unhead/{fw}/stream/vite re-export. Power users can call unheadVueStreamingPlugin.rspack(), .rollup(), etc. directly.

Other streaming changes in this PR:

  • nonce option on the streaming plugin and createBootstrapScript() β€” accepts string | () => string | undefined and is forwarded on every injected <script> tag (CSP support).
  • async mode fix for production Vite builds β€” IIFE is now emitted via this.emitFile() so the <script src> references a real hashed asset instead of the virtual /virtual:@unhead/streaming-iife.js URL.
  • Shared StreamingGlobal type at unhead/stream/types so the server bootstrap, client, and injected IIFE agree on the shape of window.__unhead__ (was a stringly-typed contract).
  • Dev-mode warning when the client IIFE runs but the server bootstrap queue never populated window[streamKey].

⚠️ Breaking Changes

  1. @unhead/bundler peer dep floors. New optional peer constraints: vite >=6.4.2, webpack >=5.0.0. Users on older Vite (e.g. <6.4.2) will see peer-dep warnings.
  2. VitePluginOptions._framework removed. Was @internal; now threaded via a second argument to the base Unhead() factory (InternalFrameworkContext). Framework wrappers use createFrameworkVitePlugin helpers, so this only affects anyone calling @unhead/bundler/vite's Unhead() directly with _framework.
  3. UnheadXxxViteOptions.streaming type narrowed. streaming?: true | Pick<StreamingPluginOptions, 'mode'> | false β†’ streaming?: true | UnheadXxxStreamingOptions. false is no longer a valid TS value; omit the key or pass undefined. Runtime behaviour is unchanged.

Deprecation shims are retained so existing imports keep working:

  • unhead/stream/vite still exports createStreamingPlugin, VIRTUAL_CLIENT_ID, VIRTUAL_IIFE_ID, StreamingPluginOptions.
  • @unhead/{react,solid-js,svelte,vue}/stream/vite still exports unheadXxxPlugin() with its prior signature, plus the raw unheadXxxStreamingPlugin unplugin instance for other bundlers.

πŸ“ Migration

Prefer the unified per-bundler entries:

- import { unheadVuePlugin } from '@unhead/vue/stream/vite'
+ import { Unhead } from '@unhead/vue/vite'

  export default defineConfig({
    plugins: [
      vue(),
-     unheadVuePlugin(),
+     Unhead({ streaming: true }),
    ],
  })

For webpack, use @unhead/{react,solid-js,svelte,vue}/webpack. For rspack, rollup, esbuild, or rolldown, reach for the underlying unplugin instance:

import { unheadVueStreamingPlugin } from '@unhead/vue/stream/vite'

const rspackPlugin = unheadVueStreamingPlugin.rspack({ /* options */ })

@coderabbitai

coderabbitai Bot commented Apr 23, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▢️ Resume reviews
  • πŸ” Trigger review
πŸ“ Walkthrough

Walkthrough

Refactors streaming plugin architecture into a cross-bundler unplugin core, re-exports core primitives, adds bundler-specific wrapper entrypoints (vite/webpack/rspack/rollup), updates framework Vite wrappers to call the new Vite-specific factory, and extends package exports/build entries for new stream targets.

Changes

Cohort / File(s) Summary
Core unplugin & Vite wrapper
packages/unhead/src/stream/unplugin.ts, packages/unhead/src/stream/vite.ts
Add new cross-bundler unplugin implementation (virtual IDs, SSR detection, load/resolve/transform, HTML injection). Replace previous full Vite implementation with a thin createStreamingVitePlugin wrapper that delegates to the unplugin.
New bundler wrappers
packages/unhead/src/stream/webpack.ts, packages/unhead/src/stream/rspack.ts, packages/unhead/src/stream/rollup.ts
Add thin adapter modules exporting createStreamingWebpackPlugin, createStreamingRspackPlugin, createStreamingRollupPlugin and re-export StreamingPluginOptions, delegating to the unplugin factories.
Framework Vite integrations
packages/react/src/stream/vite.ts, packages/solid-js/src/stream/vite.ts, packages/svelte/src/stream/vite.ts, packages/vue/src/stream/vite.ts
Switch framework Vite plugins to use createStreamingVitePlugin instead of createStreamingPlugin; tighten transform hook TypeScript signatures (code: string, id: string, opts?: { ssr?: boolean }) while preserving runtime SSR handling.
Package metadata & build
packages/unhead/package.json, packages/unhead/build.config.ts
Add ./stream/webpack, ./stream/rspack, ./stream/rollup exports and typesVersions; add build entries for new stream targets; add unplugin dependency.
Exports test update
test/exports/unhead.yaml
Update exported surface: remove createStreamingPlugin from Vite export; add buildStreamingPluginOptions, createStreamingVitePlugin, and new platform-specific plugin factories for rollup/rspack/webpack.

Sequence Diagram

sequenceDiagram
    participant App as Bundler/App
    participant Wrapper as Bundler Wrapper (vite/webpack/...)
    participant Unplugin as Unplugin Core
    participant VM as Virtual Module Resolver
    participant HTML as HTML Injection

    App->>Wrapper: initialize(options)
    Wrapper->>Unplugin: createStreamingPlugin.<bundler>(options)

    loop Plugin lifecycle
        App->>Unplugin: resolveId(moduleId)
        Unplugin->>VM: check virtual ids
        VM-->>Unplugin: resolved id
        Unplugin-->>App: return resolved id

        App->>Unplugin: load(resolvedId)
        Unplugin->>Unplugin: determine SSR mode
        alt SSR
            Unplugin-->>App: return SSR-safe stub
        else Client
            Unplugin-->>App: return client wiring or cached IIFE
        end
    end

    loop Transform Hook
        App->>Unplugin: transform(code, id, bundlerCtx/opts)
        Unplugin->>Unplugin: derive ssr boolean
        Unplugin->>Unplugin: call user transform({ ssr })
        Unplugin-->>App: transformed result or null
    end

    alt Vite only
        App->>Unplugin: transformIndexHtml
        Unplugin->>HTML: inject script based on mode (inline / async / module)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 I hopped through plugins, near and far,

Rollup, Rspack, Webpack, Vite in my jar,
Virtual IDs sparkle, SSR hides then gleam,
One unplugin binds themβ€”what a stream!
Hooray, I nibbled the build and left a carrot beam πŸ₯•βœ¨

πŸš₯ Pre-merge checks | βœ… 5
βœ… Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage βœ… Passed Docstring coverage is 92.31% which is sufficient. The required threshold is 80.00%.
Linked Issues check βœ… Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check βœ… Passed Check skipped because no linked issues were found for this pull request.
Title check βœ… Passed The title accurately captures the main change: extracting webpack plugin support and introducing a bundler-agnostic factory. It is concise and specific.
Description check βœ… Passed The description provides comprehensive coverage of all sections including linked issues, type of changes, and detailed explanation of modifications, migration path, and breaking changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 fix/missing-stream-plugins

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❀️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/unhead/src/stream/server.ts (1)

270-278: ⚠️ Potential issue | 🟠 Major

Avoid treating byte chunks as safe HTML insertion points.

ReadableStream<Uint8Array> chunk boundaries can split tags, text, <script> contents, or UTF-8 sequences. Injecting a patch after every reader.read() can corrupt output, e.g. <div cl + patch + ass="x">. Please flush only at renderer-provided safe boundaries, or narrow/enforce the API contract so callers cannot pass arbitrary byte chunks.

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/unhead/src/stream/server.ts` around lines 270 - 278, The loop treats
raw ReadableStream<Uint8Array> chunks as safe HTML boundaries by calling
flushPatch after every reader.read(); this can split UTF-8 sequences or HTML
tags (reader/getReader, controller.enqueue, flushPatch). Remove the per-chunk
flushPatch call and instead either (a) buffer and decode bytes with a
TextDecoder (or use a TextDecoderStream) to operate on full decoded strings and
only call flushPatch at renderer-provided safe boundaries, or (b) change the API
so callers provide safe-string chunks or an explicit "flush" signal from the
renderer; in short, stop flushing on every Uint8Array chunk and only flush when
you have a guaranteed safe boundary (or when stream ends).
🧹 Nitpick comments (2)
packages/react/src/stream/vite.ts (1)

1-4: Optional: consolidate imports from unhead/stream/vite.

Minor β€” StreamingPluginOptions (type) and createStreamingVitePlugin (value) could share a single import { createStreamingVitePlugin, type StreamingPluginOptions } from 'unhead/stream/vite' statement.

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react/src/stream/vite.ts` around lines 1 - 4, Consolidate the two
imports from 'unhead/stream/vite' by merging the separate type import and value
import into a single statement that imports both createStreamingVitePlugin and
the type StreamingPluginOptions together; update the existing import occurrences
for StreamingPluginOptions and createStreamingVitePlugin to use a single
combined import line so there are no duplicate module specifiers.
packages/svelte/src/stream/vite.ts (1)

1-4: Optional: consolidate imports from unhead/stream/vite.

Same as React β€” the type import on line 1 and value import on line 4 could be merged into one statement using inline type specifiers.

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/svelte/src/stream/vite.ts` around lines 1 - 4, Merge the two imports
from 'unhead/stream/vite' into a single import statement that uses inline type
specifiers: keep StreamingPluginOptions as a type and createStreamingVitePlugin
as a value in the same import. Update the import that currently references
StreamingPluginOptions and the separate createStreamingVitePlugin import so both
symbols are imported together (e.g., using "import { StreamingPluginOptions,
createStreamingVitePlugin } from 'unhead/stream/vite'" with
StreamingPluginOptions marked as a type), leaving the other imports
(MagicString, parseAndWalk) unchanged.
πŸ€– 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/unhead/src/stream/vite.ts`:
- Around line 2-13: The public export was changed and breaks existing imports
expecting createStreamingPlugin; restore a compatibility alias by re-exporting
the new createStreamingVitePlugin under the old name (createStreamingPlugin) so
downstream code keeps working. Update the exports in this module to export the
existing createStreamingVitePlugin (and its types if needed) also as
createStreamingPlugin (e.g., a re-export or const alias), ensuring
createStreamingVitePlugin(options) remains the primary implementation and the
old symbol createStreamingPlugin is available for consumers.

---

Outside diff comments:
In `@packages/unhead/src/stream/server.ts`:
- Around line 270-278: The loop treats raw ReadableStream<Uint8Array> chunks as
safe HTML boundaries by calling flushPatch after every reader.read(); this can
split UTF-8 sequences or HTML tags (reader/getReader, controller.enqueue,
flushPatch). Remove the per-chunk flushPatch call and instead either (a) buffer
and decode bytes with a TextDecoder (or use a TextDecoderStream) to operate on
full decoded strings and only call flushPatch at renderer-provided safe
boundaries, or (b) change the API so callers provide safe-string chunks or an
explicit "flush" signal from the renderer; in short, stop flushing on every
Uint8Array chunk and only flush when you have a guaranteed safe boundary (or
when stream ends).

---

Nitpick comments:
In `@packages/react/src/stream/vite.ts`:
- Around line 1-4: Consolidate the two imports from 'unhead/stream/vite' by
merging the separate type import and value import into a single statement that
imports both createStreamingVitePlugin and the type StreamingPluginOptions
together; update the existing import occurrences for StreamingPluginOptions and
createStreamingVitePlugin to use a single combined import line so there are no
duplicate module specifiers.

In `@packages/svelte/src/stream/vite.ts`:
- Around line 1-4: Merge the two imports from 'unhead/stream/vite' into a single
import statement that uses inline type specifiers: keep StreamingPluginOptions
as a type and createStreamingVitePlugin as a value in the same import. Update
the import that currently references StreamingPluginOptions and the separate
createStreamingVitePlugin import so both symbols are imported together (e.g.,
using "import { StreamingPluginOptions, createStreamingVitePlugin } from
'unhead/stream/vite'" with StreamingPluginOptions marked as a type), leaving the
other imports (MagicString, parseAndWalk) unchanged.
πŸͺ„ Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
βš™οΈ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8eae092e-2ea7-40a6-8370-5b89623173d1

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between ea54e36 and f804874.

β›” Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
πŸ“’ Files selected for processing (22)
  • examples/vite-ssr-vue-streaming/index.html
  • examples/vite-ssr-vue-streaming/src/App.vue
  • examples/vite-ssr-vue-streaming/src/entry-client.ts
  • examples/vite-ssr-vue-streaming/tests/streaming.spec.ts
  • packages/react/src/stream/vite.ts
  • packages/solid-js/src/stream/vite.ts
  • packages/svelte/src/stream/vite.ts
  • packages/unhead/build.config.ts
  • packages/unhead/package.json
  • packages/unhead/src/stream/rollup.ts
  • packages/unhead/src/stream/rspack.ts
  • packages/unhead/src/stream/server.ts
  • packages/unhead/src/stream/unplugin.ts
  • packages/unhead/src/stream/vite.ts
  • packages/unhead/src/stream/webpack.ts
  • packages/vue/build.config.ts
  • packages/vue/package.json
  • packages/vue/src/stream/client.ts
  • packages/vue/src/stream/server.ts
  • packages/vue/src/stream/vite.ts
  • packages/vue/test/streaming.test.ts
  • packages/vue/test/vite-plugin.test.ts
πŸ’€ Files with no reviewable changes (3)
  • examples/vite-ssr-vue-streaming/src/entry-client.ts
  • packages/vue/package.json
  • packages/vue/src/stream/client.ts

Comment thread packages/unhead/src/stream/vite.ts Outdated

@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: 2

πŸ€– 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/unhead/src/stream/unplugin.ts`:
- Around line 12-33: Add SSR detection to the load handler and an explicit
fallback SSR flag on StreamingPluginOptions: update the StreamingPluginOptions
interface to include an optional ssr?: boolean field, change the plugin's load
hook signature from load(id) to load(id, opts?) so it accepts the Vite/Rollup
options object, and inside load use the same SSR detection logic used by
transform (extract ssr from opts or fall back to the
closure/StreamingPluginOptions.ss r) via the shared detector function used by
transform; ensure load uses that detected ssr value to decide whether to return
browser-only client code (matching transform's behavior).
- Around line 136-160: Change the transformIndexHtml export from a plain
function to an object with order: 'pre' and the original function as handler so
injected script tags for virtual modules (VIRTUAL_IIFE_ID and VIRTUAL_CLIENT_ID)
are run before HTML processing and get resolved by the plugin pipeline; update
any call sites/tests that directly invoked transformIndexHtml() (e.g.,
packages/vue/test/vite-plugin.test.ts line that calls transformIndexHtml()) to
call transformIndexHtml.handler() instead; apply the same change to the
duplicate transformIndexHtml in packages/bundler/src/devtools/vite.ts.
πŸͺ„ Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
βš™οΈ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5e100b44-7c57-40db-b875-587cbe8d21b3

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between 65e0d17 and 7f86cf4.

πŸ“’ Files selected for processing (3)
  • packages/unhead/src/stream/unplugin.ts
  • test/exports/unhead.yaml
  • test/exports/vue.yaml

Comment on lines +12 to +33
export interface StreamingPluginOptions {
/** Framework package e.g. '@unhead/vue' */
framework: string
/** Plugin name (optional, defaults to `${framework}:streaming`) */
name?: string
/**
* File extension filter for transform hook, e.g. /\.vue$/. Optional β€”
* only required by frameworks whose client streaming support relies on
* source-level AST injection (React/Solid/Svelte). Vue does not use it.
*/
filter?: RegExp
/** Transform handler called for files matching `filter`. */
transform?: (code: string, id: string, options?: { ssr?: boolean }) => { code: string, map?: any } | null | undefined | void
/**
* How to load the streaming client (vite-only, ignored on webpack/rspack/rollup where
* index.html injection isn't available β€” frameworks inject the iife themselves in SSR).
* - 'async': Load as async script (non-blocking, may have brief queue delay)
* - 'inline': Inline the IIFE directly in HTML (larger HTML, but immediate execution)
* - 'module': Use ES module import (original behavior, waits for bundle)
* @default 'async'
*/
mode?: 'async' | 'inline' | 'module'

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:

find . -type f -name "unplugin.ts" | grep -E "stream|unhead"

Repository: unjs/unhead

Length of output: 95


🏁 Script executed:

cat -n packages/unhead/src/stream/unplugin.ts

Repository: unjs/unhead

Length of output: 7391


🏁 Script executed:

fd -t f "unplugin" | head -20

Repository: unjs/unhead

Length of output: 93


🏁 Script executed:

rg "load.*handler.*\{" -A 3 --type ts | head -40

Repository: unjs/unhead

Length of output: 37


🏁 Script executed:

web_search
unplugin load hook handler signature options parameter

Repository: unjs/unhead

Length of output: 152


🌐 Web query:

unplugin load hook handler signature parameters Vite options.ssr

πŸ’‘ Result:

The unplugin load hook handler signature is (code: string, id: string) => string | null | undefined | Promise<string | null | undefined>. This matches the standard Rollup/Vite load hook, where code is the module source code and id is the module ID/path. In Vite SSR contexts, the underlying Vite/Rollup hooks for load receive an optional options parameter containing { ssr?: boolean }, allowing plugins to detect SSR mode (e.g., if (options?.ssr) { ... }). Unplugin normalizes hooks across bundlers and passes the code and id parameters to the handler as shown in source code examples for Rspack/Webpack (which extend to Vite/Rollup). The options.ssr is available in the full plugin context but not directly as a handler parameter in the normalized unplugin API; plugins detect SSR via meta.frameworkPlugin?.framework === 'vite' combined with Vite-specific logic or this.environment.config.consumer === 'server' in Vite 6+. Example usage: load: { filter: { id: /.vue$/ }, handler(code, id) { if (id.endsWith('.vue')) { // SSR-specific: if (options?.ssr) would be in raw Vite plugin return transformVueForSSR(code); } } } Vite options.ssr is not a direct parameter to the unplugin handler but is handled by unplugin's adapter layer for Vite SSR builds.

Citations:


🏁 Script executed:

npm info unplugin 2>/dev/null | grep -A 5 version || echo "Checking unplugin in node_modules"
find . -path "*/node_modules/unplugin/dist" -type d 2>/dev/null | head -1

Repository: unjs/unhead

Length of output: 300


🏁 Script executed:

# Search for unplugin type definitions or documentation
fd -t f "unplugin" -path "*/node_modules/*" 2>/dev/null | grep -E "\.(d\.ts|ts)$" | head -5

Repository: unjs/unhead

Length of output: 37


🏁 Script executed:

# Check if there are any other load handler examples in the codebase
rg "load\s*:\s*\{" -A 5 -B 1 --type ts

Repository: unjs/unhead

Length of output: 3320


🏁 Script executed:

cat node_modules/unplugin/dist/index.d.ts 2>/dev/null | grep -A 20 "load.*handler" | head -40

Repository: unjs/unhead

Length of output: 37


🏁 Script executed:

# Check the actual transform handler signature in unplugin types
cat node_modules/unplugin/package.json 2>/dev/null | grep -A 2 '"version"'

Repository: unjs/unhead

Length of output: 37


🏁 Script executed:

# Look at unplugin load/transform hook definitions
find node_modules/unplugin -name "*.d.ts" 2>/dev/null | xargs grep -l "load\|transform" | head -3 | xargs cat 2>/dev/null | head -100

Repository: unjs/unhead

Length of output: 37


🏁 Script executed:

# Check if there are examples/tests showing load hook usage
rg "load.*handler" -B 5 -A 10 --type ts packages/

Repository: unjs/unhead

Length of output: 37


🏁 Script executed:

# Look for any Vite-specific documentation or comments about hook signatures
rg "options\.ssr|opts\?" -B 3 -A 3 --type ts packages/unhead/src/stream/

Repository: unjs/unhead

Length of output: 1288


🏁 Script executed:

# Check if transform handler's opts parameter is actually used/tested
rg "opts\?" -B 2 -A 2 --type ts packages/ | head -60

Repository: unjs/unhead

Length of output: 2975


Add SSR detection to load handler to match transform hook pattern.

The load handler at line 84 accepts only (id) but Vite passes an { ssr?: boolean } options parameter (as it does for transform at line 107). In Vite v6+ dev mode with per-environment contexts, the load hook only checks the mutable closure ssr, missing per-call SSR signals. This creates a gap: browser code could load in SSR contexts.

Additionally, StreamingPluginOptions has no explicit ssr field, preventing pure Rollup server builds from declaring SSR mode (webpack/rspack/vite all have hooks to set the closure, but Rollup has none).

Update the load handler signature to accept (id, opts?), extract SSR state consistently with transform using a shared detector function, and add an optional ssr?: boolean field to StreamingPluginOptions as an explicit fallback for bundlers without SSR hooks.

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/unhead/src/stream/unplugin.ts` around lines 12 - 33, Add SSR
detection to the load handler and an explicit fallback SSR flag on
StreamingPluginOptions: update the StreamingPluginOptions interface to include
an optional ssr?: boolean field, change the plugin's load hook signature from
load(id) to load(id, opts?) so it accepts the Vite/Rollup options object, and
inside load use the same SSR detection logic used by transform (extract ssr from
opts or fall back to the closure/StreamingPluginOptions.ss r) via the shared
detector function used by transform; ensure load uses that detected ssr value to
decide whether to return browser-only client code (matching transform's
behavior).

Comment thread packages/unhead/src/stream/unplugin.ts Outdated
@harlan-zw harlan-zw force-pushed the fix/missing-stream-plugins branch from 7f86cf4 to 4cdb110 Compare April 23, 2026 05:43
@harlan-zw harlan-zw changed the title fix(stream): add missing webpack/rspack/rollup plugins and simplify Vue streaming fix(stream): add missing webpack/rspack/rollup plugin entries Apr 23, 2026

@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.

♻️ Duplicate comments (2)
packages/unhead/src/stream/unplugin.ts (2)

12-34: ⚠️ Potential issue | 🟠 Major

Use one SSR detector for both load and transform.

load still only reads the mutable ssr closure, so Vite dev/custom server environments can receive browser code for the virtual modules. Also include this.environment.config.consumer === 'server' for Vite 6+ custom environment names, and add an explicit options.ssr fallback for Rollup/direct usage. See Vite SSR hook options and Vite’s environment migration docs: https://vite.dev/guide/ssr#ssr-specific-plugin-logic, https://vite.dev/changes/this-environment-in-hooks.

πŸ”§ Proposed fix
 export interface StreamingPluginOptions {
   /** Framework package e.g. '@unhead/vue' */
   framework: string
@@
    * `@default` 'async'
    */
   mode?: 'async' | 'inline' | 'module'
+  /** Explicit SSR fallback for bundlers/tests that do not provide a hook-level SSR signal. */
+  ssr?: boolean
 }
@@
 export function buildStreamingPluginOptions(options: StreamingPluginOptions): UnpluginOptions {
   const { framework, name, mode = 'async' } = options
   let ssr = false
+
+  const isSsrContext = (ctx?: any, opts?: { ssr?: boolean }) => {
+    const environment = ctx?.environment
+    return environment?.config?.consumer === 'server'
+      || environment?.name === 'ssr'
+      || environment?.name === 'server'
+      || opts?.ssr === true
+      || options.ssr === true
+      || ssr
+  }
@@
     load: {
       filter: { id: RESOLVED_RE },
-      handler(id) {
+      handler(this: any, id: string, opts?: { ssr?: boolean }) {
+        const isSSR = isSsrContext(this, opts)
         if (id === RESOLVED_ID) {
-          if (ssr)
+          if (isSSR)
             return { code: 'export {}' }
@@
         }
         if (id === RESOLVED_IIFE_ID) {
-          if (ssr)
+          if (isSSR)
             return { code: '' }
@@
             handler(this: any, code: string, id: string, opts?: { ssr?: boolean }) {
-              // Vite v6+ dev mode exposes environment per-transform call (one plugin
-              // instance, two environments). Fall back to the options.ssr flag
-              // (vite <=5 and tests), then the bundler-hook closure for
-              // vite build (separate instances per build) and webpack/rspack.
-              const envName = this?.environment?.name
-              const isSSR = envName === 'ssr' || envName === 'server' || opts?.ssr === true || ssr
+              const isSSR = isSsrContext(this, opts)
               return options.transform!(code, id, { ssr: isSSR })
             },

Also applies to: 82-100, 107-114

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/unhead/src/stream/unplugin.ts` around lines 12 - 34, Create a single
SSR detection helper and use it from both the load and transform hooks: detect
SSR by checking the existing mutable ssr closure (if defined and truthy), OR
this.environment.config.consumer === 'server' (for Vite 6+ custom envs), OR the
options?.ssr boolean fallback (for Rollup/direct API usage); replace the current
ad-hoc checks in load and transform so both call this helper to decide whether
to return browser or server code and ensure load no longer reads only the
mutable ssr closure.

136-160: ⚠️ Potential issue | 🟠 Major

Run virtual-module HTML injection before Vite processes HTML scripts.

These injected scripts reference /${VIRTUAL_IIFE_ID} and /${VIRTUAL_CLIENT_ID}. Vite’s transformIndexHtml docs reserve order: 'pre' for injected scripts that must pass through the plugin pipeline; otherwise production HTML can retain raw /virtual: URLs. See https://vite.dev/guide/api-plugin#transformindexhtml.

πŸ”§ Proposed fix
-      transformIndexHtml() {
-        if (mode === 'inline') {
-          if (!iifeCode)
-            throw new Error('[unhead] Streaming IIFE not built. Run `pnpm build` in packages/unhead first.')
-          return [{
-            tag: 'script',
-            children: iifeCode,
-            injectTo: 'head-prepend',
-          }]
-        }
-
-        if (mode === 'async') {
-          return [{
-            tag: 'script',
-            attrs: { async: true, src: `/${VIRTUAL_IIFE_ID}` },
-            injectTo: 'head-prepend',
-          }]
-        }
-
-        return [{
-          tag: 'script',
-          children: `import("/${VIRTUAL_CLIENT_ID}")`,
-          injectTo: 'head-prepend',
-        }]
+      transformIndexHtml: {
+        order: 'pre',
+        handler() {
+          if (mode === 'inline') {
+            if (!iifeCode)
+              throw new Error('[unhead] Streaming IIFE not built. Run `pnpm build` in packages/unhead first.')
+            return [{
+              tag: 'script',
+              children: iifeCode,
+              injectTo: 'head-prepend',
+            }]
+          }
+
+          if (mode === 'async') {
+            return [{
+              tag: 'script',
+              attrs: { async: true, src: `/${VIRTUAL_IIFE_ID}` },
+              injectTo: 'head-prepend',
+            }]
+          }
+
+          return [{
+            tag: 'script',
+            children: `import("/${VIRTUAL_CLIENT_ID}")`,
+            injectTo: 'head-prepend',
+          }]
+        },
       },

Run this read-only check for direct test/call-site invocations that need to switch to .handler(...) after this shape change:

#!/bin/bash
rg -nP '\.transformIndexHtml\s*\(' --type ts -C3
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/unhead/src/stream/unplugin.ts` around lines 136 - 160, The injected
script objects returned from transformIndexHtml must be marked to run before
Vite processes HTML scripts so virtual-module URLs are transformed; update the
three return objects in transformIndexHtml (the inline iifeCode branch, the
async branch that uses VIRTUAL_IIFE_ID, and the default branch that imports
VIRTUAL_CLIENT_ID) to include order: 'pre' on each injected script descriptor;
also scan for any direct calls to transformIndexHtml and update them to use the
new handler shape (call .handler(...) where needed) so callers match the changed
plugin API.
πŸ€– Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@packages/unhead/src/stream/unplugin.ts`:
- Around line 12-34: Create a single SSR detection helper and use it from both
the load and transform hooks: detect SSR by checking the existing mutable ssr
closure (if defined and truthy), OR this.environment.config.consumer ===
'server' (for Vite 6+ custom envs), OR the options?.ssr boolean fallback (for
Rollup/direct API usage); replace the current ad-hoc checks in load and
transform so both call this helper to decide whether to return browser or server
code and ensure load no longer reads only the mutable ssr closure.
- Around line 136-160: The injected script objects returned from
transformIndexHtml must be marked to run before Vite processes HTML scripts so
virtual-module URLs are transformed; update the three return objects in
transformIndexHtml (the inline iifeCode branch, the async branch that uses
VIRTUAL_IIFE_ID, and the default branch that imports VIRTUAL_CLIENT_ID) to
include order: 'pre' on each injected script descriptor; also scan for any
direct calls to transformIndexHtml and update them to use the new handler shape
(call .handler(...) where needed) so callers match the changed plugin API.

ℹ️ Review info
βš™οΈ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: acb79e71-688b-4e23-bb18-f135c2fe0c41

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between 7f86cf4 and 4cdb110.

β›” Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
πŸ“’ Files selected for processing (12)
  • packages/react/src/stream/vite.ts
  • packages/solid-js/src/stream/vite.ts
  • packages/svelte/src/stream/vite.ts
  • packages/unhead/build.config.ts
  • packages/unhead/package.json
  • packages/unhead/src/stream/rollup.ts
  • packages/unhead/src/stream/rspack.ts
  • packages/unhead/src/stream/unplugin.ts
  • packages/unhead/src/stream/vite.ts
  • packages/unhead/src/stream/webpack.ts
  • packages/vue/src/stream/vite.ts
  • test/exports/unhead.yaml
βœ… Files skipped from review due to trivial changes (1)
  • packages/unhead/build.config.ts
🚧 Files skipped from review as they are similar to previous changes (9)
  • packages/unhead/src/stream/webpack.ts
  • packages/vue/src/stream/vite.ts
  • packages/unhead/src/stream/rollup.ts
  • packages/unhead/src/stream/rspack.ts
  • packages/react/src/stream/vite.ts
  • packages/unhead/src/stream/vite.ts
  • packages/svelte/src/stream/vite.ts
  • packages/unhead/package.json
  • test/exports/unhead.yaml

Extracts the streaming plugin hooks into a bundler-agnostic unplugin
factory (`packages/unhead/src/stream/unplugin.ts`) and exposes per-bundler
entry points:

- `unhead/stream/vite` β†’ `createStreamingVitePlugin`
- `unhead/stream/webpack` β†’ `createStreamingWebpackPlugin`
- `unhead/stream/rspack` β†’ `createStreamingRspackPlugin`
- `unhead/stream/rollup` β†’ `createStreamingRollupPlugin`

SSR detection is wired per bundler:
- vite: `ConfigEnv.isSsrBuild`, plus per-transform `environment.name` in
  vite v6+ dev
- webpack/rspack: `compiler.options.name === 'server'`

React/Solid/Svelte/Vue vite plugins now delegate to
`createStreamingVitePlugin`; the old standalone `createStreamingPlugin`
export is removed.
@harlan-zw harlan-zw force-pushed the fix/missing-stream-plugins branch from 4cdb110 to 7feae06 Compare April 23, 2026 09:58

@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: 1

πŸ€– 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/unhead/src/stream/unplugin.ts`:
- Around line 120-128: Update the SSR detection in the webpack(compiler) and
rspack(compiler) hooks to check compiler.options.target in addition to
compiler.options.name === 'server' (e.g., treat target values 'node' or
'async-node' as SSR); do not reference options.ssr unless you first extend the
StreamingPluginOptions interface to include an explicit ssr overrideβ€”either add
that field to the interface and honor it, or rely solely on
compiler.options.name/target checks in the webpack and rspack functions (refer
to the webpack and rspack function names in the diff).
πŸͺ„ Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
βš™οΈ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e3c385d1-7fe8-48ca-af8b-bcc45350b937

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between 4cdb110 and 7feae06.

β›” Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
πŸ“’ Files selected for processing (12)
  • packages/react/src/stream/vite.ts
  • packages/solid-js/src/stream/vite.ts
  • packages/svelte/src/stream/vite.ts
  • packages/unhead/build.config.ts
  • packages/unhead/package.json
  • packages/unhead/src/stream/rollup.ts
  • packages/unhead/src/stream/rspack.ts
  • packages/unhead/src/stream/unplugin.ts
  • packages/unhead/src/stream/vite.ts
  • packages/unhead/src/stream/webpack.ts
  • packages/vue/src/stream/vite.ts
  • test/exports/unhead.yaml
βœ… Files skipped from review due to trivial changes (2)
  • packages/unhead/src/stream/webpack.ts
  • packages/unhead/build.config.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/unhead/src/stream/rollup.ts
  • packages/unhead/src/stream/rspack.ts
  • test/exports/unhead.yaml
  • packages/unhead/package.json

Comment thread packages/unhead/src/stream/unplugin.ts
…per bundler

Drops the per-framework /stream/{bundler} subpaths in favour of a single
unified Unhead() factory exposed at @unhead/{framework}/{bundler}, with
streaming opt-in via a feature flag. One plugin per (framework, bundler)
combination matches Vite/Webpack community conventions and lets the build
optimisations (tree-shaking, useSeoMeta transform, minify) compose with the
streaming hooks behind one import.

@unhead/bundler
- Add /rspack and /rollup unified-plugin entries (mirror of /webpack)

@unhead/{vue,react,svelte,solid-js}
- Refactor stream plugin to an unplugin instance at src/stream/plugin.ts,
  exposing .vite()/.webpack()/.rspack()/.rollup()/.esbuild() builders
- Add unified Unhead() per bundler at src/{vite,webpack,rspack,rollup}.ts
  that composes @unhead/bundler + the framework streaming plugin
- Drop @unhead/{framework}/stream/vite (no replacement; use the unified
  /{bundler} entry with streaming: true)
- Add unplugin as a direct dep

unhead
- Drop /stream/{vite,webpack,rspack,rollup} (trivial wrappers, redundant
  now that frameworks consume the unplugin factory directly)
- Expose /stream/unplugin (buildStreamingPluginOptions, createStreamingPlugin,
  virtual id constants) as the single canonical core entry for plugin authors

docs
- Update Vue/React/Solid/Svelte streaming guides to the new Unhead() API
…eam/vite

Restores @unhead/{vue,react,solid-js,svelte}/stream/vite as a deprecation
shim re-exporting the new unheadXxxStreamingPlugin via its .vite() adapter
under the old unheadXxxPlugin name. Prevents the 751 rename from being a
breaking change; users are nudged to Unhead({ streaming: true }) via JSDoc
@deprecated. The shim will be removed in a future major.

Tests
- Extend each framework's vite-plugin.test.ts with smoke tests for the
  .webpack/.rspack/.rollup adapters on the unplugin instance
- Add unified-plugin.test.ts per framework verifying Unhead({ streaming: true })
  returns a plugin array with the streaming plugin present on each bundler
- Confirm the deprecated unheadXxxPlugin alias still returns a valid vite plugin

vitest
- Add @unhead/bundler/{vite,webpack,rspack,rollup} aliases to vue's vitest
  config so the new per-bundler unified entries resolve to source
Three issues surfaced by CodeRabbit on PR 751:

1. load hook missed Vite v6+ per-environment SSR signal. Extract a shared
   isSSRCall(this, opts) detector (envName + opts.ssr + closure fallback)
   and use it from both load and transform. Prevents serving browser
   bootstrap code into SSR contexts in dev.

2. transformIndexHtml ran at default order, separate from the plugin-level
   enforce: 'pre'. Wrap in { order: 'pre', handler } so our virtual-module
   <script> tags are injected before non-pre HTML transforms touch them.
   Apply the same fix to the devtools bundler vite plugin which injects a
   bridge import the same way.

3. webpack/rspack SSR detection checked only compiler.options.name === 'server',
   which is convention not universal. Also treat target: 'node' /
   'async-node' as SSR. Matches how webpack SSR is typically configured.

Vue vite-plugin test updated to call transformIndexHtml.handler() now that
transformIndexHtml is an object, and asserts order === 'pre'.
@harlan-zw

Copy link
Copy Markdown
Collaborator Author

Code review

Found 1 issue:

  1. unhead/stream/vite subpath export is removed without a deprecation stub, unlike the @unhead/{framework}/stream/vite entries which correctly got back-compat shims. This is a breaking change on a published subpath (v3.0.5) β€” downstream code importing createStreamingPlugin, VIRTUAL_CLIENT_ID, VIRTUAL_IIFE_ID, or StreamingPluginOptions from unhead/stream/vite will hit a module-not-found error. CodeRabbit flagged this in the first review on the original packages/unhead/src/stream/vite.ts and it was not marked as addressed. Restore the subpath as a deprecation layer re-exporting from ./stream/unplugin, matching the pattern used for the framework packages.

Previously at line 76:

"default": "./dist/parser.mjs"
},
"./stream/vite": {
"types": "./dist/stream/vite.d.ts",
"default": "./dist/stream/vite.mjs"
},
"./stream/iife": {

Current state at that line:

"default": "./dist/parser.mjs"
},
"./stream/unplugin": {
"types": "./dist/stream/unplugin.d.ts",
"default": "./dist/stream/unplugin.mjs"
},
"./stream/iife": {

πŸ€– Generated with Claude Code

- If this code review was useful, please react with πŸ‘. Otherwise, react with πŸ‘Ž.

…moduleType

Two regressions surfaced by review:

1. unhead/stream/vite subpath (on main v3.0.5) was removed outright while
   the framework-level /stream/vite entries got deprecation shims. Restore
   it as a stub re-exporting VIRTUAL_CLIENT_ID, VIRTUAL_IIFE_ID,
   StreamingPluginOptions, buildStreamingPluginOptions from ./unplugin, and
   exposing createStreamingPlugin as a vite-plugin function backed by
   .vite() on the unplugin instance. Marked @deprecated with a migration
   snippet pointing at unhead/stream/unplugin. Wired back through
   package.json exports/typesVersions and build.config entries.

2. moduleType: 'js' was dropped from the load hook returns. It was
   explicitly added in df9c846 for Rolldown compatibility with virtual
   modules (Rolldown requires explicit moduleType; other bundlers ignore
   it). Restored on all four branches (RESOLVED_ID / RESOLVED_IIFE_ID,
   SSR / client) with a comment citing the original fix.

Exports snapshot picks up the new ./stream/vite: shape alongside
./stream/unplugin: so both consumers can keep working.
Each framework package (@unhead/{vue,react,solid-js,svelte}) now exposes
per-bundler entries (/vite, /webpack, /rspack, /rollup). Previously only
`vite` was declared as an optional peer. Add `webpack`, `@rspack/core`, and
`rollup` alongside for:
- Better install-time signals when a bundler subpath is imported without
  the bundler installed.
- Consistency with the existing optional-peer pattern.
- TypeScript types resolution for bundler-specific return types.

@unhead/bundler also gets `webpack`, `@rspack/core`, `rollup`, and `vite`
added as optional peers to match its /webpack, /rspack, /rollup, /vite
subpath entries.

Version floors picked to cover the realistic min versions: webpack >=5,
@rspack/core >=1, rollup >=4. vite stays at >=6.4.2.
Folds the per-framework Unhead() wrappers into a shared factory in
@unhead/bundler/framework so each framework/bundler entry becomes a
thin parameterization instead of 20 near-identical files. Extends the
multi-bundler story with esbuild and rolldown entries across the
bundler and framework packages.

Streaming plugin:
- Add `nonce: string | () => string` option forwarded on every
  injected <script> tag; createBootstrapScript() accepts a nonce too.
- Add dev-mode warning when the client IIFE runs but the server
  bootstrap queue never populated window[streamKey].
- Fix 'async' mode for production Vite builds: emit the IIFE via
  this.emitFile() so the <script src> references a real hashed asset
  instead of the virtual /virtual:@unhead/streaming-iife.js URL.
- Default mode changed from 'async' to 'inline' (smallest TTFB,
  always safe in production).
- Extract a shared StreamingGlobal type in unhead/stream/types so the
  server bootstrap, client, and injected IIFE agree on the shape of
  window.__unhead__ (was a stringly-typed contract).

Plugin factory:
- Move `_framework` off the public VitePluginOptions surface; it now
  flows through a separate internal 2nd arg to the base factory.
- Typed Plugin[] return on rollup entry; drop the `| false` tri-state
  on `streaming` option.

Tests:
- New regression guard asserts the streaming plugin runs last in the
  vite plugin array (enforce: 'pre' dependency on prior transforms).

Docs + snapshots updated.
@harlan-zw harlan-zw changed the title fix(stream): add missing webpack/rspack/rollup plugin entries feat: webpack/rspack/rollup plugins Apr 24, 2026
Scopes the streaming plugin work down to vite + webpack. Power users on
rspack/rollup/esbuild/rolldown reach for the framework's streaming
unplugin instance directly (e.g. `unheadVueStreamingPlugin.rspack()`),
which is still re-exported from `@unhead/{fw}/stream/vite`.

Removes:
- `@unhead/{react,solid-js,svelte,vue}/{rspack,rollup,esbuild,rolldown}`
- `@unhead/bundler/{rspack,rollup,esbuild,rolldown}`
- The matching `createFramework{Rspack,Rollup,Esbuild,Rolldown}Plugin`
  helpers from `@unhead/bundler/framework`
- `@rspack/core`, `rollup` peer-dep declarations from framework packages
@harlan-zw harlan-zw changed the title feat: webpack/rspack/rollup plugins feat(stream): webpack plugin + bundler-agnostic factory Apr 24, 2026
@harlan-zw harlan-zw merged commit 3ed063c into main Apr 24, 2026
7 of 8 checks passed
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