Deno
-
The Registry Rewrote My Import and My Catch Block Covered For It
Users installing my logger from JSR were getting this at runtime:
[logan-logger] Winston not found, falling back to console logging: Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/path/to/node_modules/.pnpm/@[email protected]/node_modules/@jsr/logan__logger/src/runtime/winston' imported from .../@jsr/logan__logger/src/runtime/node.jsWinston was installed. It was right there in their
dependencies. The message was wrong, and it was my message, printed by my code, from a catch block I wrote.Look at the path it failed on.
src/runtime/winston. Notnode_modules/winston. It was looking for a file next to my own source, one directory deep in my package.The source I published isn’t the source I wrote
What I wrote was ordinary:
const winston = await import('winston');A bare specifier, resolved by the package manager the way every bare specifier has been since forever. What ended up in the published tarball was this:
const winston = await import('./winston');I confirmed it by pulling the actual artifact from
https://npm.jsr.io/~/11/@jsr/logan__logger/1.1.16.tgzand readingsrc/runtime/node.tsinside it. The registry changed my code betweendeno publishand the file on disk in a user’snode_modules.The reason is that JSR statically analyzes ESM imports at publish time. Bare specifiers have to be explicitly mapped to a known specifier, something like
npm:winston, in yourjsr.jsonordeno.jsonimports map. Winston wasn’t in mine, because it was an optional peer dependency. The whole point was that it might not be there. So the analyzer, finding a bare specifier it couldn’t resolve to anything it knew about, treated it as a relative path and normalized it to./winston.Node then did exactly what it should: looked for
./winstonrelative to the module, found nothing, and threwERR_MODULE_NOT_FOUND.The catch block is the actual villain
The import was wrapped in a try/catch, and that catch existed for a legitimate reason. Winston was optional. If it wasn’t installed, falling back to console logging was correct behavior.
try { const winston = await import('winston'); this.winston = this.createWinstonLogger(winston); } catch (error) { console.warn('[logan-logger] Winston not found, falling back to console logging:', error); }That catch cannot tell the difference between “the user didn’t install winston” and “the registry mangled my import statement.” Both arrive as
ERR_MODULE_NOT_FOUND. So a packaging defect got laundered into a routine, expected, entirely normal-looking warning.The consequence for users wasn’t a crash. It was worse. They silently lost file transports and production JSON formatting, and got console output instead, while a message on stderr confidently told them the cause was a missing dependency they’d already installed. If you’re going to debug that, you have to distrust your own error message first.
The npm build was fine the whole time, because Vite externalizes
winstoncorrectly during the bundler build. Only the JSR distribution was broken. One library, two registries, two different published sources, one of which nobody was checking.There’s also a false fix in the history I should own. A commit from 2025-12-03 titled “handle optional Winston dependency with TypeScript ignore” added
// @ts-ignorecomments around this code. It silenced a type complaint and touched nothing about the import statement, so it did nothing for the actual bug while looking, in the log, like the bug had been addressed.The fix I’m not proud of
// Dynamic specifier prevents JSR from rewriting the bare 'winston' // import to a relative './winston' path during publish. const winstonModule = 'winston'; const winston = await import(winstonModule);Assign the string to a variable first. The static analyzer can’t follow it, so it leaves the import alone, and at runtime the value is identical.
This works. I shipped it. It’s also a workaround that operates by deliberately defeating static analysis, which is the same capability the tooling is trying to give you. The build systems are all moving toward being able to see your dependency graph, and my fix was to hide from them.
deno publish --dry-runwill even warn you about it,unanalyzable-dynamic-import, which in this case was the goal.The alternative was declaring
winstonas annpm:specifier indeno.json, which pins JSR consumers to a specific npm package and breaks the optional-peer semantics the dependency existed to provide. I picked the hack.Then I deleted the dependency
That wasn’t a reaction to the workaround being ugly, the 2.0 work was already queued. But writing that comment is what made the cost legible. I had a peer dependency that couldn’t be declared, published through a pipeline that rewrote it, hidden behind a catch block that misreported it, patched with a trick that lied to the analyzer.
The numbers on the way out:
pnpm-lock.yamllost 205 linessrc/runtime/node.tswent to 84 lines with zero dynamic imports, replaced by an owned transport layerdeno publish --dry-runreports zerounanalyzable-dynamic-importwarnings against it- The library now has no runtime dependencies at all
I kept
docs/jsr-winston-import-bug.mdin the repo, with a banner at the top saying it’s resolved. The failure mode outlived the dependency and it’ll happen to somebody else.Three things I’d tell you to check today:
- Download your own published tarball and read it. Not the git tag, the artifact.
npm pack, or pull the.tgzfrom the registry, and diff the source you shipped against the source you wrote. If you publish to two registries, do it twice. - Never let a catch block report a cause it can’t distinguish. “Winston not found” was an inference from an error code, and the inference was wrong. Log what happened, and be honest that you’re guessing about why.
- Optional dependencies and static analysis are in direct conflict. A tool that resolves every import at publish time has no way to express “this one might not exist.” If you’re publishing to JSR with an optional peer, you will hit this.
Happy Saturday. Go read your tarball.
Sources
- JSR: Publishing packages — the static analysis and import requirements behind the rewrite
- JSR troubleshooting — including the unanalyzable dynamic import warning
- Node.js ERR_MODULE_NOT_FOUND — the error the catch block was swallowing
- docs/jsr-winston-import-bug.md — the full writeup kept in the repo, tarball inspection included
- @logan/logger on JSR — the package, now dependency-free
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].