Skip to content

Vite-compatible built-in asset handling #21275

Description

@bjohansebas

Have you used AI?

Yes

Feature Proposal

Make webpack handle static assets the way Vite does, out of the box:

import logo from "./logo.svg";          // → URL (auto data-URI under webpack's threshold), zero config
import raw  from "./doc.md?raw";        // → file source as a string
import url  from "./img.png?url";        // → emitted file URL (explicit)
import data from "./img.png?inline";     // → data-URI, regardless of size
import file from "./img.png?no-inline";  // → forced emitted file (never data-URI)

Two layers, each independently acceptable, listed cheapest-first so the
discussion can stop at either:

  1. Query suffixes?raw / ?url / ?inline / ?no-inline.
  2. Built-in asset extension recognitionimport x from "./logo.svg" works
    with no module.rules (images, media, fonts, …), using type: "asset" and
    webpack's existing inline threshold (no change to it).

Both map onto asset module types webpack already has — no new module type, and
no change to the auto-inline threshold. Layer 2 is the opinionated one: it bakes
an extension list into core, which webpack has deliberately avoided so far. That
trade-off is the heart of this discussion.

Feature Use Case

Today the decision "import this file as a string / URL / data-URI" lives in
webpack.config.js, keyed on the file's name, and importing a .svg/.png
at all requires a rule. Every project re-writes the same boilerplate, and it is
easy to get subtly wrong.

Concrete example — examples/markdown/webpack.config.mjs imports one markdown
file as a string today. It costs two filename-coupled edits:

// 1) a dedicated rule keyed on the filename
{ test: /raw-to-string\.md$/, type: "asset/source" },

// 2) AND that same filename must be excluded from the html-loader rule
{
  test: /\.(md|markdown|)$/i,
  exclude: /(raw-to-string|raw-to-uint8-array)\.md/,
  loader: "html-loader",
  /* … */
}

With ?raw both the rule and the exclude disappear and the call site says what
it wants: import md from "./whatever.md?raw".

What the manual equivalent looks like today

To replicate Vite's query suffixes with current webpack you write:

module.exports = {
  module: {
    rules: [
      {
        oneOf: [
          // order matters: `no-inline` must precede `inline`, because a naive
          // /inline/ also matches "no-inline".
          { resourceQuery: /(\?|&)raw(&|$)/,       type: "asset/source" },
          { resourceQuery: /(\?|&)url(&|$)/,       type: "asset/resource" },
          { resourceQuery: /(\?|&)no-inline(&|$)/, type: "asset/resource" },
          { resourceQuery: /(\?|&)inline(&|$)/,    type: "asset/inline" }
        ]
      },
      // plus a rule per asset extension you want to import without a query…
      { test: /\.(png|jpe?g|gif|svg|webp|avif|woff2?|)$/i, type: "asset" }
    ]
  }
};

This is boilerplate every project repeats, and the inline/no-inline regex
overlap plus oneOf ordering are real footguns.

Use cases

Because HTML <img src> and CSS url() resolve through the same module pipeline
(the query is preserved end to end, verified by test/configCases/html/sources
and test/configCases/css/url), all of this applies there too, not only in JS.

  • SVG / images in HTML (directly relevant to experiments.html): inline a
    small icon as a data-URI on one element, keep another as an emitted file — per
    element, not globally:

    <img src="./logo.svg?inline">  <!-- → src="data:image/svg+xml,…" -->
    <img src="./hero.jpg?url">     <!-- → src="hero.<hash>.jpg" (emitted) -->

    This is the data-URI form. Inlining an SVG as raw <svg> DOM markup (so it is
    styleable) is a separate, magic-comment-based feature — see Out of scope.

  • CSS url(): inline vs emitted per declaration
    (url(./bg.png?inline) / url(./photo.jpg?no-inline)).

  • JS / TS source as text via ?raw: shaders, SQL, templates, markdown.

  • ?url for Web APIs: a stable asset URL for new Audio(), <video>,
    fetch(), or a worker, never inlined.

Prior art (Vite, verified against its docs)

  • No query → default export is the resolved URL; assets under assetsInlineLimit
    (Vite's default: 4096 bytes) are auto-inlined as base64 data URIs, the rest
    emitted.
  • Query suffixes: ?url, ?raw, ?inline, ?no-inline (plus ?worker etc.,
    out of scope).
  • Common image / media / font filetypes are detected as assets automatically
    (KNOWN_ASSET_TYPES) — zero config.

Intentional divergence: this proposal keeps webpack's existing auto-inline
threshold (module.parser.asset.dataUrlCondition.maxSize, default 8096 bytes,
size <= maxSize ⇒ inline) rather than adopting Vite's 4096 — following webpack's
own convention instead of changing a default everyone already relies on.

Proposed behavior

Layer 1 — query suffixes

Append a resourceQuery oneOf to module.defaultRules:

Query Maps to Result
?raw asset/source file source as a string
?url asset/resource emitted file URL
?no-inline asset/resource force emit (never data-URI)
?inline asset/inline data-URI regardless of size

Appended last, so a query wins over the extension-based type. defaultRules
compile before user module.rules, so a user's own ?raw/etc. rule still
overrides. Modules with no query, or a non-matching query (?foo=bar), are
unaffected — the rules are query-gated and purely additive.

Layer 2 — built-in asset extension recognition

Add default rules mirroring Vite's KNOWN_ASSET_TYPES, each type: "asset"
(automatic inline-or-emit using webpack's existing 8096-byte threshold — no
per-rule dataUrlCondition, no threshold change
):

  • images: png jpg jpeg jfif pjpeg pjp gif svg ico webp avif
  • media: mp4 webm ogg mp3 wav flac aac opus mov m4a vtt
  • fonts: woff woff2 eot ttf otf
  • other: webmanifest pdf txt

(Exact list pinned to Vite's at implementation time.) Because defaultRules run
before user rules, anyone with an existing file-loader / type: "asset/*" rule
for these extensions keeps it.

Additional Context

Compatibility / risks

  • Baked-in extension list (Layer 2) is the philosophical shift, webpack
    intentionally requires opt-in asset rules. Maintaining the list in core is an
    ongoing cost. → gate behind futureDefaults.

  • No threshold change: the auto-inline limit stays at webpack's 8096, scoped
    by reusing the global asset default; existing type: "asset" users see no
    change.

  • Query-word collisions with existing user query conventions → mitigated by
    precedence (user rules win) and futureDefaults gating.

  • The default assetModuleFilename is [hash][ext][query], so emitted names
    include ?url / ?no-inline. Cosmetic; may want to strip recognized flags.

  • Interaction with the dependency: "url" rule (new URL(...)): a query on
    a new URL() target would also honor ?inline / ?url.

    • I think we should honor ?inline, ?url, and ?no-inline in new URL() for consistency, but ?raw in that context should either be ignored or emit a warning, since it doesn't really fit the semantics of new URL().

Open Questions

  1. Mirror Vite's KNOWN_ASSET_TYPES exactly, or a trimmed list? https://github.com/vitejs/vite/blob/dae9bb10cfc7acabaebadfcda82d16530ade748d/packages/vite/src/node/constants.ts#L146
  2. Keeping webpack's 8096-byte threshold (vs Vite's 4096), agreed?
  3. Strip recognized flags (?url, …) from [query] in emitted filenames?
  4. Should ?raw apply to any module (incl. .js), matching Vite?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions