The Deno loader plugin in @fresh/plugin-vite (src/plugins/deno.ts) passes
Vite module ids to Deno's loader without removing the query string. Vite's
dependency optimizer appends ?v=<hash> to dependency ids, so the loader is
handed a path that does not exist and the dev server returns 500.
Enabling optimizeDeps at all is enough to trigger it on a freshly scaffolded
project. Either environment does it — the repro below uses the client, and
moving the same config under environments.ssr fails identically.
@fresh/core |
2.3.3 |
@fresh/plugin-vite |
1.1.2 |
vite |
7.3.6 |
deno |
2.9.4 (aarch64-apple-darwin) |
./run.sh
app/ is an unmodified deno run -Ar jsr:@fresh/init@^2 scaffold. The only
edit is app/vite.config.ts, which switches optimizeDeps on from an
environment variable so both runs differ in nothing else:
const optimize = Deno.env.get('OPTIMIZE_DEPS') === '1'
export default defineConfig({
plugins: [fresh()],
...(optimize ? { optimizeDeps: { include: ['preact'] } } : {}),
server: { port: 8123 },
})run.sh starts the dev server each way, clearing Vite's dependency cache
first, and requests /:
without optimizeDeps: HTTP 200
with optimizeDeps : HTTP 500
ENOENT: no such file or directory, open
'.../preact@10.29.7/node_modules/preact/debug/dist/debug.module.js?v=ff8da874'
Expected: the page renders in both cases.
Actual: with optimizeDeps the server 500s, repeatedly, for any module
whose id the optimizer has versioned.
Vite identifies modules by a string that may carry a query. For a dependency
the optimizer has versioned, that id is <absolute path>?v=<hash>, where the
query is cache-busting metadata rather than part of the filename.
src/plugins/deno.ts passes the id straight to toFileUrl:
const url = path.toFileUrl(id);
const result = await loader.load(url.href, meta.type);toFileUrl() treats its whole argument as a filesystem path, so ? is
percent-encoded instead of being kept as a query delimiter:
/tmp/x/mod.js → file:///tmp/x/mod.js
/tmp/x/mod.js?v=9bd7f6e5 → file:///tmp/x/mod.js%3Fv=9bd7f6e5
The %3F is now part of the filename, no such file exists, and the loader
reports ENOENT with the query still visible in the path — which is what makes
the error confusing at first sight.
repro.ts demonstrates this on its own, with no Vite or Fresh involved:
deno run -A repro.ts
id as Vite passes it WITHOUT client optimizeDeps
load : ok (module)
id as Vite passes it WITH client optimizeDeps (?v=<hash> appended)
load : FAILED: Import 'file:///.../mod.js%3Fv=9bd7f6e5' failed, not found.
same id, query stripped first (the workaround)
load : ok (module)
Both environments are affected. The repro enables optimizeDeps for the
client. Moving it to environments.ssr produces the same failure, on the SSR
build of the same module:
ENOENT: no such file or directory, open
'.../preact/debug/dist/debug.mjs?v=ca7e3d92'
The failing module is not the one being optimized. The config above adds
preact to optimizeDeps, and the error is raised for preact/debug. The
optimizer versions dependency ids whether or not it pre-bundles them, so the
failure lands on whichever module is versioned first. Excluding one module just
moves the error to the next.
Queries are already handled, but case by case. Further down
src/plugins/deno.ts:
actualId = actualId.replace("?commonjs-es-import", "")so one known query is stripped while others are not.
Strip any query before handing the id to toFileUrl, rather than matching
known queries:
const filePath = id.split('?')[0];
const url = path.toFileUrl(filePath);A plugin with enforce: 'pre' that strips the query in resolveId works as a
local workaround, but it has to be applied to every environment that reaches
the Deno loader.